From 460aeab5f9d38757a702640d01dcb73ec36c4275 Mon Sep 17 00:00:00 2001 From: irving ou Date: Wed, 20 May 2026 16:37:25 -0400 Subject: [PATCH 1/6] 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: 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 [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. --- .../Actions/AgentActions.cs | 13 + .../Actions/CustomActions.cs | 117 ++++++ .../Dialogs/AgentTunnelDialog.Designer.cs | 371 ++++++++++++++++++ .../Dialogs/AgentTunnelDialog.cs | 83 ++++ package/AgentWindowsManaged/Dialogs/Wizard.cs | 23 +- package/AgentWindowsManaged/Program.cs | 11 + .../Properties/AgentProperties.cs | 15 + .../Resources/DevolutionsAgent_en-us.wxl | 10 + .../Resources/DevolutionsAgent_fr-fr.wxl | 9 + .../AgentWindowsManaged/Resources/Features.cs | 5 + 10 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs create mode 100644 package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs diff --git a/package/AgentWindowsManaged/Actions/AgentActions.cs b/package/AgentWindowsManaged/Actions/AgentActions.cs index 223f1a261..4832e753a 100644 --- a/package/AgentWindowsManaged/Actions/AgentActions.cs +++ b/package/AgentWindowsManaged/Actions/AgentActions.cs @@ -279,6 +279,18 @@ 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, + }; + private static readonly ElevatedManagedAction registerExplorerCommand = new( CustomActions.RegisterExplorerCommand ) @@ -352,6 +364,7 @@ private static string UseProperties(IEnumerable properties) setArpInstallLocation, setFeaturesToConfigure, configureFeatures, + enrollAgentTunnel, createProgramDataDirectory, setProgramDataDirectoryPermissions, createProgramDataPedmDirectories, diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index 96546c55c..e2c4ea7e7 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -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; @@ -318,6 +319,122 @@ 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; + + if (enrollmentString.Length == 0) + { + session.Log("Agent tunnel enrollment string not provided, skipping tunnel setup"); + return ActionResult.Success; + } + + 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); + + string arguments = $"up --enrollment-string \"{enrollmentString}\""; + 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 */ } + session.Log("Enrollment process timed out after 60 seconds"); + return ActionResult.Failure; + } + 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) + { + session.Log($"Enrollment failed with exit code {process.ExitCode}"); + return ActionResult.Failure; + } + + if (domainsArg.Length != 0) + { + WriteAdvertiseDomainsToConfig(session, domainsArg); + } + + session.Log("Agent tunnel enrollment completed successfully"); + return ActionResult.Success; + } + catch (Exception e) + { + session.Log($"Agent tunnel enrollment failed: {e}"); + return ActionResult.Failure; + } + } + + 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) { diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs new file mode 100644 index 000000000..87c73bfc6 --- /dev/null +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs @@ -0,0 +1,371 @@ +using WixSharp; +using WixSharp.UI.Forms; + +namespace WixSharpSetup.Dialogs +{ + partial class AgentTunnelDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.middlePanel = new System.Windows.Forms.Panel(); + this.tableLayoutPanel2 = new System.Windows.Forms.TableLayoutPanel(); + this.labelEnrollmentString = new System.Windows.Forms.Label(); + this.enrollmentString = new System.Windows.Forms.TextBox(); + this.labelSubnets = new System.Windows.Forms.Label(); + this.advertiseSubnets = new System.Windows.Forms.TextBox(); + this.labelSubnetsHint = new System.Windows.Forms.Label(); + this.labelDomains = new System.Windows.Forms.Label(); + this.advertiseDomains = new System.Windows.Forms.TextBox(); + this.labelDomainsHint = new System.Windows.Forms.Label(); + this.topBorder = new System.Windows.Forms.Panel(); + this.topPanel = new System.Windows.Forms.Panel(); + this.label2 = new System.Windows.Forms.Label(); + this.label1 = new System.Windows.Forms.Label(); + this.banner = new System.Windows.Forms.PictureBox(); + this.bottomPanel = new System.Windows.Forms.Panel(); + this.tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel(); + this.back = new System.Windows.Forms.Button(); + this.next = new System.Windows.Forms.Button(); + this.cancel = new System.Windows.Forms.Button(); + this.border1 = new System.Windows.Forms.Panel(); + this.middlePanel.SuspendLayout(); + this.tableLayoutPanel2.SuspendLayout(); + this.topPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.banner)).BeginInit(); + this.bottomPanel.SuspendLayout(); + this.tableLayoutPanel1.SuspendLayout(); + this.SuspendLayout(); + // + // middlePanel + // + this.middlePanel.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.middlePanel.AutoScroll = true; + this.middlePanel.Controls.Add(this.tableLayoutPanel2); + this.middlePanel.Location = new System.Drawing.Point(22, 75); + this.middlePanel.Name = "middlePanel"; + this.middlePanel.Size = new System.Drawing.Size(449, 225); + this.middlePanel.TabIndex = 0; + // + // tableLayoutPanel2 + // + this.tableLayoutPanel2.ColumnCount = 1; + this.tableLayoutPanel2.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + this.tableLayoutPanel2.Controls.Add(this.labelEnrollmentString, 0, 0); + this.tableLayoutPanel2.Controls.Add(this.enrollmentString, 0, 1); + this.tableLayoutPanel2.Controls.Add(this.labelSubnets, 0, 2); + this.tableLayoutPanel2.Controls.Add(this.advertiseSubnets, 0, 3); + this.tableLayoutPanel2.Controls.Add(this.labelSubnetsHint, 0, 4); + this.tableLayoutPanel2.Controls.Add(this.labelDomains, 0, 5); + this.tableLayoutPanel2.Controls.Add(this.advertiseDomains, 0, 6); + this.tableLayoutPanel2.Controls.Add(this.labelDomainsHint, 0, 7); + this.tableLayoutPanel2.AutoSize = true; + this.tableLayoutPanel2.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + this.tableLayoutPanel2.Dock = System.Windows.Forms.DockStyle.Top; + this.tableLayoutPanel2.Location = new System.Drawing.Point(0, 0); + this.tableLayoutPanel2.Name = "tableLayoutPanel2"; + this.tableLayoutPanel2.RowCount = 8; + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.Size = new System.Drawing.Size(449, 285); + this.tableLayoutPanel2.TabIndex = 0; + // + // labelEnrollmentString + // + this.labelEnrollmentString.AutoSize = true; + this.labelEnrollmentString.BackColor = System.Drawing.Color.Transparent; + this.labelEnrollmentString.Location = new System.Drawing.Point(3, 3); + this.labelEnrollmentString.Margin = new System.Windows.Forms.Padding(3); + this.labelEnrollmentString.Name = "labelEnrollmentString"; + this.labelEnrollmentString.Size = new System.Drawing.Size(200, 13); + this.labelEnrollmentString.TabIndex = 0; + this.labelEnrollmentString.Text = "[AgentTunnelDlgEnrollmentStringLabel]"; + // + // enrollmentString + // + this.enrollmentString.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.enrollmentString.Location = new System.Drawing.Point(3, 22); + this.enrollmentString.Multiline = true; + this.enrollmentString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.enrollmentString.Name = "enrollmentString"; + this.enrollmentString.Size = new System.Drawing.Size(443, 60); + this.enrollmentString.TabIndex = 1; + // + // labelSubnets + // + this.labelSubnets.AutoSize = true; + this.labelSubnets.BackColor = System.Drawing.Color.Transparent; + this.labelSubnets.Location = new System.Drawing.Point(3, 93); + this.labelSubnets.Margin = new System.Windows.Forms.Padding(3, 8, 3, 3); + this.labelSubnets.Name = "labelSubnets"; + this.labelSubnets.Size = new System.Drawing.Size(200, 13); + this.labelSubnets.TabIndex = 2; + this.labelSubnets.Text = "[AgentTunnelDlgSubnetsLabel]"; + // + // advertiseSubnets + // + this.advertiseSubnets.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.advertiseSubnets.Location = new System.Drawing.Point(3, 112); + this.advertiseSubnets.Name = "advertiseSubnets"; + this.advertiseSubnets.Size = new System.Drawing.Size(443, 20); + this.advertiseSubnets.TabIndex = 3; + // + // labelSubnetsHint + // + this.labelSubnetsHint.AutoSize = true; + this.labelSubnetsHint.BackColor = System.Drawing.Color.Transparent; + this.labelSubnetsHint.ForeColor = System.Drawing.SystemColors.GrayText; + this.labelSubnetsHint.Location = new System.Drawing.Point(3, 138); + this.labelSubnetsHint.Margin = new System.Windows.Forms.Padding(3, 3, 3, 3); + this.labelSubnetsHint.Name = "labelSubnetsHint"; + this.labelSubnetsHint.Size = new System.Drawing.Size(300, 13); + this.labelSubnetsHint.TabIndex = 4; + this.labelSubnetsHint.Text = "[AgentTunnelDlgSubnetsHint]"; + // + // labelDomains + // + this.labelDomains.AutoSize = true; + this.labelDomains.BackColor = System.Drawing.Color.Transparent; + this.labelDomains.Location = new System.Drawing.Point(3, 162); + this.labelDomains.Margin = new System.Windows.Forms.Padding(3, 8, 3, 3); + this.labelDomains.Name = "labelDomains"; + this.labelDomains.Size = new System.Drawing.Size(200, 13); + this.labelDomains.TabIndex = 5; + this.labelDomains.Text = "[AgentTunnelDlgDomainsLabel]"; + // + // advertiseDomains + // + this.advertiseDomains.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.advertiseDomains.Location = new System.Drawing.Point(3, 181); + this.advertiseDomains.Name = "advertiseDomains"; + this.advertiseDomains.Size = new System.Drawing.Size(443, 20); + this.advertiseDomains.TabIndex = 6; + // + // labelDomainsHint + // + this.labelDomainsHint.AutoSize = true; + this.labelDomainsHint.BackColor = System.Drawing.Color.Transparent; + this.labelDomainsHint.ForeColor = System.Drawing.SystemColors.GrayText; + this.labelDomainsHint.Location = new System.Drawing.Point(3, 207); + this.labelDomainsHint.Margin = new System.Windows.Forms.Padding(3, 3, 3, 3); + this.labelDomainsHint.Name = "labelDomainsHint"; + this.labelDomainsHint.Size = new System.Drawing.Size(300, 13); + this.labelDomainsHint.TabIndex = 7; + this.labelDomainsHint.Text = "[AgentTunnelDlgDomainsHint]"; + // + // topBorder + // + this.topBorder.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.topBorder.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.topBorder.Location = new System.Drawing.Point(0, 58); + this.topBorder.Name = "topBorder"; + this.topBorder.Size = new System.Drawing.Size(494, 1); + this.topBorder.TabIndex = 15; + // + // topPanel + // + this.topPanel.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.topPanel.BackColor = System.Drawing.SystemColors.Control; + this.topPanel.Controls.Add(this.label2); + this.topPanel.Controls.Add(this.label1); + this.topPanel.Controls.Add(this.banner); + this.topPanel.Location = new System.Drawing.Point(0, 0); + this.topPanel.Name = "topPanel"; + this.topPanel.Size = new System.Drawing.Size(494, 58); + this.topPanel.TabIndex = 10; + // + // label2 + // + this.label2.AutoEllipsis = true; + this.label2.BackColor = System.Drawing.Color.Transparent; + this.label2.ForeColor = System.Drawing.SystemColors.HighlightText; + this.label2.Location = new System.Drawing.Point(18, 31); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(409, 24); + this.label2.TabIndex = 1; + this.label2.Text = "[AgentTunnelDlgDescription]"; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.BackColor = System.Drawing.Color.Transparent; + this.label1.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.label1.ForeColor = System.Drawing.SystemColors.HighlightText; + this.label1.Location = new System.Drawing.Point(11, 8); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(109, 13); + this.label1.TabIndex = 1; + this.label1.Text = "[AgentTunnelDlgTitle]"; + // + // banner + // + this.banner.BackColor = System.Drawing.Color.White; + this.banner.Location = new System.Drawing.Point(0, 0); + this.banner.Name = "banner"; + this.banner.Size = new System.Drawing.Size(494, 58); + this.banner.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.banner.TabIndex = 0; + this.banner.TabStop = false; + // + // bottomPanel + // + this.bottomPanel.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.bottomPanel.BackColor = System.Drawing.SystemColors.Control; + this.bottomPanel.Controls.Add(this.tableLayoutPanel1); + this.bottomPanel.Controls.Add(this.border1); + this.bottomPanel.Location = new System.Drawing.Point(0, 312); + this.bottomPanel.Name = "bottomPanel"; + this.bottomPanel.Size = new System.Drawing.Size(494, 49); + this.bottomPanel.TabIndex = 9; + // + // tableLayoutPanel1 + // + this.tableLayoutPanel1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right))); + this.tableLayoutPanel1.ColumnCount = 5; + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 14F)); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + this.tableLayoutPanel1.Controls.Add(this.back, 1, 0); + this.tableLayoutPanel1.Controls.Add(this.next, 2, 0); + this.tableLayoutPanel1.Controls.Add(this.cancel, 4, 0); + this.tableLayoutPanel1.Location = new System.Drawing.Point(0, 3); + this.tableLayoutPanel1.Name = "tableLayoutPanel1"; + this.tableLayoutPanel1.RowCount = 1; + this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + this.tableLayoutPanel1.Size = new System.Drawing.Size(493, 43); + this.tableLayoutPanel1.TabIndex = 8; + // + // back + // + this.back.Anchor = System.Windows.Forms.AnchorStyles.Right; + this.back.AutoSize = true; + this.back.Location = new System.Drawing.Point(224, 10); + this.back.MinimumSize = new System.Drawing.Size(75, 0); + this.back.Name = "back"; + this.back.Size = new System.Drawing.Size(77, 23); + this.back.TabIndex = 1; + this.back.Text = "[WixUIBack]"; + this.back.UseVisualStyleBackColor = true; + this.back.Click += new System.EventHandler(this.Back_Click); + // + // next + // + this.next.Anchor = System.Windows.Forms.AnchorStyles.Right; + this.next.AutoSize = true; + this.next.Location = new System.Drawing.Point(307, 10); + this.next.MinimumSize = new System.Drawing.Size(75, 0); + this.next.Name = "next"; + this.next.Size = new System.Drawing.Size(77, 23); + this.next.TabIndex = 0; + this.next.Text = "[WixUINext]"; + this.next.UseVisualStyleBackColor = true; + this.next.Click += new System.EventHandler(this.Next_Click); + // + // cancel + // + this.cancel.Anchor = System.Windows.Forms.AnchorStyles.Right; + this.cancel.AutoSize = true; + this.cancel.Location = new System.Drawing.Point(404, 10); + this.cancel.MinimumSize = new System.Drawing.Size(75, 0); + this.cancel.Name = "cancel"; + this.cancel.Size = new System.Drawing.Size(86, 23); + this.cancel.TabIndex = 2; + this.cancel.Text = "[WixUICancel]"; + this.cancel.UseVisualStyleBackColor = true; + this.cancel.Click += new System.EventHandler(this.Cancel_Click); + // + // border1 + // + this.border1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.border1.Dock = System.Windows.Forms.DockStyle.Top; + this.border1.Location = new System.Drawing.Point(0, 0); + this.border1.Name = "border1"; + this.border1.Size = new System.Drawing.Size(494, 1); + this.border1.TabIndex = 14; + // + // AgentTunnelDialog + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.ClientSize = new System.Drawing.Size(494, 361); + this.Controls.Add(this.middlePanel); + this.Controls.Add(this.topBorder); + this.Controls.Add(this.topPanel); + this.Controls.Add(this.bottomPanel); + this.Name = "AgentTunnelDialog"; + this.Load += new System.EventHandler(this.OnLoad); + this.middlePanel.ResumeLayout(false); + this.tableLayoutPanel2.ResumeLayout(false); + this.tableLayoutPanel2.PerformLayout(); + this.topPanel.ResumeLayout(false); + this.topPanel.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.banner)).EndInit(); + this.bottomPanel.ResumeLayout(false); + this.tableLayoutPanel1.ResumeLayout(false); + this.tableLayoutPanel1.PerformLayout(); + this.ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.PictureBox banner; + private System.Windows.Forms.Panel topPanel; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.Panel bottomPanel; + private System.Windows.Forms.Panel border1; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; + private System.Windows.Forms.Button back; + private System.Windows.Forms.Button next; + private System.Windows.Forms.Button cancel; + private System.Windows.Forms.Panel topBorder; + private System.Windows.Forms.Panel middlePanel; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel2; + private System.Windows.Forms.Label labelEnrollmentString; + private System.Windows.Forms.TextBox enrollmentString; + private System.Windows.Forms.Label labelSubnets; + private System.Windows.Forms.TextBox advertiseSubnets; + private System.Windows.Forms.Label labelSubnetsHint; + private System.Windows.Forms.Label labelDomains; + private System.Windows.Forms.TextBox advertiseDomains; + private System.Windows.Forms.Label labelDomainsHint; + } +} diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs new file mode 100644 index 000000000..a5a724f6f --- /dev/null +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs @@ -0,0 +1,83 @@ +using DevolutionsAgent.Dialogs; +using DevolutionsAgent.Properties; + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Windows.Forms; + +using WixSharp; + +namespace WixSharpSetup.Dialogs; + +public partial class AgentTunnelDialog : AgentDialog +{ + public AgentTunnelDialog() + { + InitializeComponent(); + label1.MakeTransparentOn(banner); + label2.MakeTransparentOn(banner); + } + + public override bool ToProperties() + { + Runtime.Session[AgentProperties.AgentTunnelEnrollmentString] = enrollmentString.Text.Trim(); + Runtime.Session[AgentProperties.AgentTunnelAdvertiseSubnets] = advertiseSubnets.Text.Trim(); + Runtime.Session[AgentProperties.AgentTunnelAdvertiseDomains] = advertiseDomains.Text.Trim(); + + return true; + } + + public override void OnLoad(object sender, EventArgs e) + { + banner.Image = Runtime.Session.GetResourceBitmap("WixUI_Bmp_Banner"); + + enrollmentString.Text = Runtime.Session.Property(AgentProperties.AgentTunnelEnrollmentString); + advertiseSubnets.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseSubnets); + advertiseDomains.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseDomains); + + base.OnLoad(sender, e); + } + + public override bool DoValidate() + { + // Tunnel is optional — if enrollment string is empty, skip tunnel setup entirely. + if (string.IsNullOrWhiteSpace(enrollmentString.Text)) + { + return true; + } + + // JWT shape: three base64url segments separated by dots. The agent's `up --enrollment-string` + // parses the JWT claims for jet_gw_url / jet_agent_name, so the dialog only sanity-checks + // shape and base64url decodability here; signature verification happens at the gateway. + string text = Regex.Replace(enrollmentString.Text, @"\s+", ""); + string[] parts = text.Split('.'); + if (parts.Length != 3 || parts.Any(string.IsNullOrEmpty)) + { + ShowValidationErrorString("Enrollment string must be a JWT (three base64url segments separated by dots)."); + return false; + } + foreach (string seg in parts) + { + string b64 = seg.Replace('-', '+').Replace('_', '/'); + b64 = b64.PadRight((b64.Length + 3) & ~3, '='); + try { _ = Convert.FromBase64String(b64); } + catch (FormatException) + { + ShowValidationErrorString("Enrollment string is not valid base64url."); + return false; + } + } + + return true; + } + + // ReSharper disable once RedundantOverriddenMember + protected override void Back_Click(object sender, EventArgs e) => base.Back_Click(sender, e); + + // ReSharper disable once RedundantOverriddenMember + protected override void Next_Click(object sender, EventArgs e) => base.Next_Click(sender, e); + + // ReSharper disable once RedundantOverriddenMember + protected override void Cancel_Click(object sender, EventArgs e) => base.Cancel_Click(sender, e); +} diff --git a/package/AgentWindowsManaged/Dialogs/Wizard.cs b/package/AgentWindowsManaged/Dialogs/Wizard.cs index df1f71f78..bbb34c86f 100644 --- a/package/AgentWindowsManaged/Dialogs/Wizard.cs +++ b/package/AgentWindowsManaged/Dialogs/Wizard.cs @@ -1,5 +1,6 @@ using DevolutionsAgent.Helpers; using DevolutionsAgent.Properties; +using DevolutionsAgent.Resources; using Microsoft.Deployment.WindowsInstaller; using System; using System.Collections.Generic; @@ -21,6 +22,7 @@ static Wizard() { typeof(WelcomeDialog), typeof(FeaturesDialog), + typeof(AgentTunnelDialog), typeof(InstallDirDialog), }; @@ -28,7 +30,7 @@ static Wizard() Sequence = dialogs.ToArray(); } - + internal static IEnumerable Dialogs => Sequence; internal static int Move(IManagedDialog current, bool forward) @@ -36,10 +38,27 @@ internal static int Move(IManagedDialog current, bool forward) Type t = current.GetType(); int index = Dialogs.FindIndex(t); - index = forward ? index + 1 : index - 1; + // Skip dialogs whose preconditions aren't met (e.g. feature unselected). + // Iterating handles both forward and back traversal symmetrically. + while (true) + { + index = forward ? index + 1 : index - 1; + if (index < 0 || index >= Sequence.Length) break; + if (!ShouldSkip(Sequence[index], current)) break; + } return index; } + private static bool ShouldSkip(Type dialogType, IManagedDialog current) + { + if (dialogType == typeof(AgentTunnelDialog)) + { + string addlocal = (current as WixSharp.UI.Forms.ManagedForm)?.MsiRuntime?.Session?["ADDLOCAL"] ?? string.Empty; + return !addlocal.Split(',').Select(s => s.Trim()).Contains(Features.AGENT_TUNNEL_FEATURE.Id); + } + return false; + } + internal static int GetNext(IManagedDialog current) => Move(current, true); internal static int GetPrevious(IManagedDialog current) => Move(current, false); diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 47338d33a..14b412519 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -324,6 +324,17 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, Feature = Features.AGENT_FEATURE, + }, + // Anchors the AGENT_TUNNEL_FEATURE to a real Component so it shows + // up in the Feature table and the Custom Setup tree. The value + // itself doubles as a diagnostic marker that the feature was + // selected at install time. + new (RegistryHive.LocalMachine, $"Software\\{Includes.VENDOR_NAME}\\{Includes.SHORT_NAME}", "TunnelEnabled", "1") + { + AttributesDefinition = "Type=string", + Win64 = project.Platform == Platform.x64, + RegistryKeyAction = RegistryKeyAction.create, + Feature = Features.AGENT_TUNNEL_FEATURE, } }; diff --git a/package/AgentWindowsManaged/Properties/AgentProperties.cs b/package/AgentWindowsManaged/Properties/AgentProperties.cs index 1010fb969..73e773198 100644 --- a/package/AgentWindowsManaged/Properties/AgentProperties.cs +++ b/package/AgentWindowsManaged/Properties/AgentProperties.cs @@ -16,6 +16,21 @@ internal partial class AgentProperties /// public static string InstallDir = "INSTALLDIR"; + /// + /// Agent tunnel enrollment string (dgw-enroll:v1:...) + /// + public static string AgentTunnelEnrollmentString = "AGENT_TUNNEL_ENROLLMENT_STRING"; + + /// + /// Comma-separated subnets to advertise (e.g., "10.10.0.0/24, 192.168.1.0/24") + /// + public static string AgentTunnelAdvertiseSubnets = "AGENT_TUNNEL_ADVERTISE_SUBNETS"; + + /// + /// Comma-separated DNS domains to advertise (e.g., "corp.example.com, lab.example.com") + /// + public static string AgentTunnelAdvertiseDomains = "AGENT_TUNNEL_ADVERTISE_DOMAINS"; + public AgentProperties(ISession runtimeSession) { this.runtimeSession = runtimeSession; diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl index ba52af25b..c54536849 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl @@ -9,6 +9,8 @@ Devolutions PEDM Installs the RDP Extension RDP Extension + Agent Tunnel + Configure the agent to connect to a Devolutions Gateway via QUIC tunnel. Requires an enrollment string from Devolutions Server, Hub, or Gateway. 1033 System-wide service for extending Devolutions Gateway functionality. Devolutions Inc. @@ -57,4 +59,12 @@ If it appears minimized then active it from the taskbar. Ready to update [ProductName] Welcome to the [ProductName] 20[ProductVersion] Setup Wizard + + Agent Tunnel Configuration + Configure connection to a Devolutions Gateway via QUIC tunnel. + Enrollment String (paste the JWT from Devolutions Server, Hub, or Gateway): + Advertise Subnets: + Comma-separated CIDR notation, e.g. 10.10.0.0/24, 192.168.1.0/24. Leave blank for auto-detection. + Advertise Domains: + Comma-separated DNS suffixes the agent can resolve, e.g. corp.example.com, lab.example.com. Leave blank to skip. diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl index eb2213bd0..1ebe7b183 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl @@ -3,6 +3,15 @@ Installe l'extension RDP Extension RDP + Tunnel d'agent + Configurer l'agent pour se connecter à une passerelle Devolutions via un tunnel QUIC. Nécessite une chaîne d'enrôlement depuis Devolutions Server, Hub, ou Gateway. + Configuration du tunnel d'agent + Configurer la connexion à une passerelle Devolutions via un tunnel QUIC. + Chaîne d'enrôlement (collez le JWT depuis Devolutions Server, Hub ou Gateway) : + Sous-réseaux annoncés : + Notation CIDR séparée par des virgules, p. ex. 10.10.0.0/24, 192.168.1.0/24. Laissez vide pour la détection automatique. + Domaines annoncés : + Suffixes DNS séparés par des virgules que l'agent peut résoudre, p. ex. corp.example.com, lab.example.com. Laissez vide pour ignorer. 1036 Service à l’échelle du système pour étendre les fonctionnalités de Devolutions Gateway. Devolutions Inc. diff --git a/package/AgentWindowsManaged/Resources/Features.cs b/package/AgentWindowsManaged/Resources/Features.cs index f6f1f98ef..43ae35bbb 100644 --- a/package/AgentWindowsManaged/Resources/Features.cs +++ b/package/AgentWindowsManaged/Resources/Features.cs @@ -33,6 +33,11 @@ internal static class Features { Id = $"{FEATURE_ID_PREFIX}Session" }; + + internal static Feature AGENT_TUNNEL_FEATURE = new("!(loc.FeatureAgentTunnelName)", "!(loc.FeatureAgentTunnelDescription)", isEnabled: false, allowChange: true) + { + Id = $"{FEATURE_ID_PREFIX}Tunnel" + }; } internal class FeatureList From a6f0298edb9ea853439d50f64b2dac1a8b826fed Mon Sep 17 00:00:00 2001 From: irving ou Date: Thu, 21 May 2026 14:26:06 -0400 Subject: [PATCH 2/6] fixup: nest AGENT_TUNNEL_FEATURE under AGENT_FEATURE in Features tree --- package/AgentWindowsManaged/Resources/Features.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package/AgentWindowsManaged/Resources/Features.cs b/package/AgentWindowsManaged/Resources/Features.cs index 43ae35bbb..4408d44b0 100644 --- a/package/AgentWindowsManaged/Resources/Features.cs +++ b/package/AgentWindowsManaged/Resources/Features.cs @@ -17,11 +17,16 @@ internal static class Features Id = $"{FEATURE_ID_PREFIX}Updater" }; + internal static Feature AGENT_TUNNEL_FEATURE = new("!(loc.FeatureAgentTunnelName)", "!(loc.FeatureAgentTunnelDescription)", isEnabled: false, allowChange: true) + { + Id = $"{FEATURE_ID_PREFIX}Tunnel" + }; + internal static Feature AGENT_FEATURE = new("!(loc.FeatureAgentName)", isEnabled: true, allowChange: false) { - Id = $"{FEATURE_ID_PREFIX}Agent", + Id = $"{FEATURE_ID_PREFIX}Agent", Description = "!(loc.FeatureAgentDescription)", - Children = [ AGENT_UPDATER_FEATURE ] + Children = [ AGENT_UPDATER_FEATURE, AGENT_TUNNEL_FEATURE ] }; internal static Feature PEDM_FEATURE = new("!(loc.FeaturePedmName)", "!(loc.FeaturePedmDescription)", isEnabled: false) @@ -33,11 +38,6 @@ internal static class Features { Id = $"{FEATURE_ID_PREFIX}Session" }; - - internal static Feature AGENT_TUNNEL_FEATURE = new("!(loc.FeatureAgentTunnelName)", "!(loc.FeatureAgentTunnelDescription)", isEnabled: false, allowChange: true) - { - Id = $"{FEATURE_ID_PREFIX}Tunnel" - }; } internal class FeatureList From cc9dff4595115597a9975089d36a9058346abc6e Mon Sep 17 00:00:00 2001 From: irving ou Date: Thu, 21 May 2026 15:21:45 -0400 Subject: [PATCH 3/6] feat(agent-installer): gateway URL override field in dialog --- .../Actions/CustomActions.cs | 2 + .../Dialogs/AgentTunnelDialog.Designer.cs | 41 +++++++++++++++++++ .../Dialogs/AgentTunnelDialog.cs | 2 + .../Properties/AgentProperties.cs | 9 +++- .../Resources/DevolutionsAgent_en-us.wxl | 2 + .../Resources/DevolutionsAgent_fr-fr.wxl | 2 + 6 files changed, 57 insertions(+), 1 deletion(-) diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index e2c4ea7e7..3602c55b9 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -325,6 +325,7 @@ 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; if (enrollmentString.Length == 0) { @@ -342,6 +343,7 @@ public static ActionResult EnrollAgentTunnel(Session session) string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME); string arguments = $"up --enrollment-string \"{enrollmentString}\""; + if (gatewayUrlArg.Length != 0) arguments += $" --gateway \"{gatewayUrlArg}\""; if (subnetsArg.Length != 0) arguments += $" --advertise-subnets \"{subnetsArg}\""; string Redact(string s) => s.Replace(enrollmentString, "***"); diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs index 87c73bfc6..974f3b2d2 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs @@ -41,6 +41,9 @@ private void InitializeComponent() this.labelDomains = new System.Windows.Forms.Label(); this.advertiseDomains = new System.Windows.Forms.TextBox(); this.labelDomainsHint = new System.Windows.Forms.Label(); + this.labelGatewayUrl = new System.Windows.Forms.Label(); + this.gatewayUrl = new System.Windows.Forms.TextBox(); + this.labelGatewayUrlHint = new System.Windows.Forms.Label(); this.topBorder = new System.Windows.Forms.Panel(); this.topPanel = new System.Windows.Forms.Panel(); this.label2 = new System.Windows.Forms.Label(); @@ -84,6 +87,9 @@ private void InitializeComponent() this.tableLayoutPanel2.Controls.Add(this.labelDomains, 0, 5); this.tableLayoutPanel2.Controls.Add(this.advertiseDomains, 0, 6); this.tableLayoutPanel2.Controls.Add(this.labelDomainsHint, 0, 7); + this.tableLayoutPanel2.Controls.Add(this.labelGatewayUrl, 0, 8); + this.tableLayoutPanel2.Controls.Add(this.gatewayUrl, 0, 9); + this.tableLayoutPanel2.Controls.Add(this.labelGatewayUrlHint, 0, 10); this.tableLayoutPanel2.AutoSize = true; this.tableLayoutPanel2.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; this.tableLayoutPanel2.Dock = System.Windows.Forms.DockStyle.Top; @@ -187,6 +193,38 @@ private void InitializeComponent() this.labelDomainsHint.TabIndex = 7; this.labelDomainsHint.Text = "[AgentTunnelDlgDomainsHint]"; // + // labelGatewayUrl + // + this.labelGatewayUrl.AutoSize = true; + this.labelGatewayUrl.BackColor = System.Drawing.Color.Transparent; + this.labelGatewayUrl.Location = new System.Drawing.Point(3, 231); + this.labelGatewayUrl.Margin = new System.Windows.Forms.Padding(3, 8, 3, 3); + this.labelGatewayUrl.Name = "labelGatewayUrl"; + this.labelGatewayUrl.Size = new System.Drawing.Size(200, 13); + this.labelGatewayUrl.TabIndex = 8; + this.labelGatewayUrl.Text = "[AgentTunnelDlgGatewayUrlLabel]"; + // + // gatewayUrl + // + this.gatewayUrl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.gatewayUrl.Location = new System.Drawing.Point(3, 250); + this.gatewayUrl.Name = "gatewayUrl"; + this.gatewayUrl.Size = new System.Drawing.Size(443, 20); + this.gatewayUrl.TabIndex = 9; + // + // labelGatewayUrlHint + // + this.labelGatewayUrlHint.AutoSize = true; + this.labelGatewayUrlHint.BackColor = System.Drawing.Color.Transparent; + this.labelGatewayUrlHint.ForeColor = System.Drawing.SystemColors.GrayText; + this.labelGatewayUrlHint.Location = new System.Drawing.Point(3, 276); + this.labelGatewayUrlHint.Margin = new System.Windows.Forms.Padding(3, 3, 3, 3); + this.labelGatewayUrlHint.Name = "labelGatewayUrlHint"; + this.labelGatewayUrlHint.Size = new System.Drawing.Size(300, 13); + this.labelGatewayUrlHint.TabIndex = 10; + this.labelGatewayUrlHint.Text = "[AgentTunnelDlgGatewayUrlHint]"; + // // topBorder // this.topBorder.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) @@ -367,5 +405,8 @@ private void InitializeComponent() private System.Windows.Forms.Label labelDomains; private System.Windows.Forms.TextBox advertiseDomains; private System.Windows.Forms.Label labelDomainsHint; + private System.Windows.Forms.Label labelGatewayUrl; + private System.Windows.Forms.TextBox gatewayUrl; + private System.Windows.Forms.Label labelGatewayUrlHint; } } diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs index a5a724f6f..304fe117e 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs @@ -24,6 +24,7 @@ public override bool ToProperties() Runtime.Session[AgentProperties.AgentTunnelEnrollmentString] = enrollmentString.Text.Trim(); Runtime.Session[AgentProperties.AgentTunnelAdvertiseSubnets] = advertiseSubnets.Text.Trim(); Runtime.Session[AgentProperties.AgentTunnelAdvertiseDomains] = advertiseDomains.Text.Trim(); + Runtime.Session[AgentProperties.AgentTunnelGatewayUrl] = gatewayUrl.Text.Trim(); return true; } @@ -35,6 +36,7 @@ public override void OnLoad(object sender, EventArgs e) enrollmentString.Text = Runtime.Session.Property(AgentProperties.AgentTunnelEnrollmentString); advertiseSubnets.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseSubnets); advertiseDomains.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseDomains); + gatewayUrl.Text = Runtime.Session.Property(AgentProperties.AgentTunnelGatewayUrl); base.OnLoad(sender, e); } diff --git a/package/AgentWindowsManaged/Properties/AgentProperties.cs b/package/AgentWindowsManaged/Properties/AgentProperties.cs index 73e773198..4aa6365f3 100644 --- a/package/AgentWindowsManaged/Properties/AgentProperties.cs +++ b/package/AgentWindowsManaged/Properties/AgentProperties.cs @@ -17,7 +17,7 @@ internal partial class AgentProperties public static string InstallDir = "INSTALLDIR"; /// - /// Agent tunnel enrollment string (dgw-enroll:v1:...) + /// Agent tunnel enrollment string (DVLS-signed JWT verbatim) /// public static string AgentTunnelEnrollmentString = "AGENT_TUNNEL_ENROLLMENT_STRING"; @@ -31,6 +31,13 @@ internal partial class AgentProperties /// public static string AgentTunnelAdvertiseDomains = "AGENT_TUNNEL_ADVERTISE_DOMAINS"; + /// + /// Optional gateway URL override. When set, the agent uses this URL instead of the JWT's + /// jet_gw_url claim. Useful when the JWT was minted with a URL that isn't reachable from + /// the agent's network (e.g. DVLS embedded "localhost" but the agent is remote). + /// + public static string AgentTunnelGatewayUrl = "AGENT_TUNNEL_GATEWAY_URL"; + public AgentProperties(ISession runtimeSession) { this.runtimeSession = runtimeSession; diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl index c54536849..1c4e67813 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl @@ -67,4 +67,6 @@ If it appears minimized then active it from the taskbar. Comma-separated CIDR notation, e.g. 10.10.0.0/24, 192.168.1.0/24. Leave blank for auto-detection. Advertise Domains: Comma-separated DNS suffixes the agent can resolve, e.g. corp.example.com, lab.example.com. Leave blank to skip. + Gateway URL (advanced, optional): + Override the URL embedded in the enrollment JWT. Leave blank to use the JWT's value. diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl index 1ebe7b183..791721572 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl @@ -12,6 +12,8 @@ Notation CIDR séparée par des virgules, p. ex. 10.10.0.0/24, 192.168.1.0/24. Laissez vide pour la détection automatique. Domaines annoncés : Suffixes DNS séparés par des virgules que l'agent peut résoudre, p. ex. corp.example.com, lab.example.com. Laissez vide pour ignorer. + URL de la passerelle (avancé, facultatif) : + Remplace l'URL incluse dans le JWT d'enrôlement. Laissez vide pour utiliser la valeur du JWT. 1036 Service à l’échelle du système pour étendre les fonctionnalités de Devolutions Gateway. Devolutions Inc. From 34fbf464924474d6499653acb6c567ac73f7d12a Mon Sep 17 00:00:00 2001 From: irving ou Date: Thu, 21 May 2026 16:54:44 -0400 Subject: [PATCH 4/6] fixup: bump TableLayoutPanel RowCount to 11 for new gateway URL field WixSharp's runtime dialog loader threw at AgentTunnelDialog init (MSI 1603) because the tableLayoutPanel had RowCount=8 but the new gateway URL controls were placed at rows 8/9/10. --- .../Dialogs/AgentTunnelDialog.Designer.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs index 974f3b2d2..2c4f3c0f4 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs @@ -95,7 +95,10 @@ private void InitializeComponent() this.tableLayoutPanel2.Dock = System.Windows.Forms.DockStyle.Top; this.tableLayoutPanel2.Location = new System.Drawing.Point(0, 0); this.tableLayoutPanel2.Name = "tableLayoutPanel2"; - this.tableLayoutPanel2.RowCount = 8; + this.tableLayoutPanel2.RowCount = 11; + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); From e48b80137f7cc2fb1087abf3da06631c52a446dd Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 16:22:41 -0400 Subject: [PATCH 5/6] feat(agent-installer): require enrollment string, surface CA errors, add Agent name field - Propagate AGENT_TUNNEL_* properties to deferred CA via Secure MSI Property declarations + explicit UsesProperties string. The deferred CA was previously seeing empty values because the wizard-set properties never crossed the UAC boundary. - Treat empty enrollment string as install failure (was silent skip). EnrollAgentTunnel CA now returns ActionResult.Failure and surfaces session.Message(InstallMessage.Error, ...) on the empty case and on enrollment timeout, non-zero exit, and exception paths. - Add optional Agent name field to AgentTunnelDialog. Resolution order at install time: dialog value > JWT jet_agent_name claim > computer name. Avoids "missing required --name" failures when the JWT lacks the claim. - Update Wizard.ShouldSkip-gated dialog so blank enrollment is blocked at UI validation (previously the dialog let users click Next on empty). --- .../Actions/AgentActions.cs | 11 ++++ .../Actions/CustomActions.cs | 51 +++++++++++++--- .../Dialogs/AgentTunnelDialog.Designer.cs | 61 ++++++++++++++++--- .../Dialogs/AgentTunnelDialog.cs | 8 ++- package/AgentWindowsManaged/Program.cs | 8 +++ .../Properties/AgentProperties.cs | 6 ++ .../Resources/DevolutionsAgent_en-us.wxl | 2 + .../Resources/DevolutionsAgent_fr-fr.wxl | 2 + 8 files changed, 129 insertions(+), 20 deletions(-) diff --git a/package/AgentWindowsManaged/Actions/AgentActions.cs b/package/AgentWindowsManaged/Actions/AgentActions.cs index 4832e753a..5add55239 100644 --- a/package/AgentWindowsManaged/Actions/AgentActions.cs +++ b/package/AgentWindowsManaged/Actions/AgentActions.cs @@ -289,6 +289,17 @@ internal static class AgentActions { Execute = Execute.deferred, Impersonate = false, + // Deferred CAs only see properties bubbled through CustomActionData. The Set__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( diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index 3602c55b9..a10f47652 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -326,11 +326,20 @@ public static ActionResult EnrollAgentTunnel(Session session) 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) { - session.Log("Agent tunnel enrollment string not provided, skipping tunnel setup"); - return ActionResult.Success; + 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 @@ -342,8 +351,18 @@ public static ActionResult EnrollAgentTunnel(Session session) 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, "***"); @@ -362,8 +381,7 @@ public static ActionResult EnrollAgentTunnel(Session session) if (!process.WaitForExit(60_000)) { try { process.Kill(); } catch { /* already gone */ } - session.Log("Enrollment process timed out after 60 seconds"); - return ActionResult.Failure; + return Fail("Agent tunnel enrollment timed out after 60 seconds."); } string stdout = process.StandardOutput.ReadToEnd(); string stderr = process.StandardError.ReadToEnd(); @@ -373,8 +391,8 @@ public static ActionResult EnrollAgentTunnel(Session session) if (process.ExitCode != 0) { - session.Log($"Enrollment failed with exit code {process.ExitCode}"); - return ActionResult.Failure; + string detail = !string.IsNullOrWhiteSpace(stderr) ? Redact(stderr).Trim() : $"exit code {process.ExitCode}"; + return Fail($"Agent tunnel enrollment failed: {detail}"); } if (domainsArg.Length != 0) @@ -387,8 +405,25 @@ public static ActionResult EnrollAgentTunnel(Session session) } catch (Exception e) { - session.Log($"Agent tunnel enrollment failed: {e}"); - return ActionResult.Failure; + 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; } } diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs index 2c4f3c0f4..19e7dc328 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs @@ -35,6 +35,9 @@ private void InitializeComponent() this.tableLayoutPanel2 = new System.Windows.Forms.TableLayoutPanel(); this.labelEnrollmentString = new System.Windows.Forms.Label(); this.enrollmentString = new System.Windows.Forms.TextBox(); + this.labelAgentName = new System.Windows.Forms.Label(); + this.agentName = new System.Windows.Forms.TextBox(); + this.labelAgentNameHint = new System.Windows.Forms.Label(); this.labelSubnets = new System.Windows.Forms.Label(); this.advertiseSubnets = new System.Windows.Forms.TextBox(); this.labelSubnetsHint = new System.Windows.Forms.Label(); @@ -81,21 +84,27 @@ private void InitializeComponent() this.tableLayoutPanel2.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); this.tableLayoutPanel2.Controls.Add(this.labelEnrollmentString, 0, 0); this.tableLayoutPanel2.Controls.Add(this.enrollmentString, 0, 1); - this.tableLayoutPanel2.Controls.Add(this.labelSubnets, 0, 2); - this.tableLayoutPanel2.Controls.Add(this.advertiseSubnets, 0, 3); - this.tableLayoutPanel2.Controls.Add(this.labelSubnetsHint, 0, 4); - this.tableLayoutPanel2.Controls.Add(this.labelDomains, 0, 5); - this.tableLayoutPanel2.Controls.Add(this.advertiseDomains, 0, 6); - this.tableLayoutPanel2.Controls.Add(this.labelDomainsHint, 0, 7); - this.tableLayoutPanel2.Controls.Add(this.labelGatewayUrl, 0, 8); - this.tableLayoutPanel2.Controls.Add(this.gatewayUrl, 0, 9); - this.tableLayoutPanel2.Controls.Add(this.labelGatewayUrlHint, 0, 10); + this.tableLayoutPanel2.Controls.Add(this.labelAgentName, 0, 2); + this.tableLayoutPanel2.Controls.Add(this.agentName, 0, 3); + this.tableLayoutPanel2.Controls.Add(this.labelAgentNameHint, 0, 4); + this.tableLayoutPanel2.Controls.Add(this.labelSubnets, 0, 5); + this.tableLayoutPanel2.Controls.Add(this.advertiseSubnets, 0, 6); + this.tableLayoutPanel2.Controls.Add(this.labelSubnetsHint, 0, 7); + this.tableLayoutPanel2.Controls.Add(this.labelDomains, 0, 8); + this.tableLayoutPanel2.Controls.Add(this.advertiseDomains, 0, 9); + this.tableLayoutPanel2.Controls.Add(this.labelDomainsHint, 0, 10); + this.tableLayoutPanel2.Controls.Add(this.labelGatewayUrl, 0, 11); + this.tableLayoutPanel2.Controls.Add(this.gatewayUrl, 0, 12); + this.tableLayoutPanel2.Controls.Add(this.labelGatewayUrlHint, 0, 13); this.tableLayoutPanel2.AutoSize = true; this.tableLayoutPanel2.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; this.tableLayoutPanel2.Dock = System.Windows.Forms.DockStyle.Top; this.tableLayoutPanel2.Location = new System.Drawing.Point(0, 0); this.tableLayoutPanel2.Name = "tableLayoutPanel2"; - this.tableLayoutPanel2.RowCount = 11; + this.tableLayoutPanel2.RowCount = 14; + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); @@ -132,6 +141,35 @@ private void InitializeComponent() this.enrollmentString.Size = new System.Drawing.Size(443, 60); this.enrollmentString.TabIndex = 1; // + // labelAgentName + // + this.labelAgentName.AutoSize = true; + this.labelAgentName.BackColor = System.Drawing.Color.Transparent; + this.labelAgentName.Margin = new System.Windows.Forms.Padding(3, 8, 3, 3); + this.labelAgentName.Name = "labelAgentName"; + this.labelAgentName.Size = new System.Drawing.Size(200, 13); + this.labelAgentName.TabIndex = 11; + this.labelAgentName.Text = "[AgentTunnelDlgAgentNameLabel]"; + // + // agentName + // + this.agentName.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.agentName.Name = "agentName"; + this.agentName.Size = new System.Drawing.Size(443, 20); + this.agentName.TabIndex = 12; + // + // labelAgentNameHint + // + this.labelAgentNameHint.AutoSize = true; + this.labelAgentNameHint.BackColor = System.Drawing.Color.Transparent; + this.labelAgentNameHint.ForeColor = System.Drawing.SystemColors.GrayText; + this.labelAgentNameHint.Margin = new System.Windows.Forms.Padding(3, 3, 3, 3); + this.labelAgentNameHint.Name = "labelAgentNameHint"; + this.labelAgentNameHint.Size = new System.Drawing.Size(300, 13); + this.labelAgentNameHint.TabIndex = 13; + this.labelAgentNameHint.Text = "[AgentTunnelDlgAgentNameHint]"; + // // labelSubnets // this.labelSubnets.AutoSize = true; @@ -402,6 +440,9 @@ private void InitializeComponent() private System.Windows.Forms.TableLayoutPanel tableLayoutPanel2; private System.Windows.Forms.Label labelEnrollmentString; private System.Windows.Forms.TextBox enrollmentString; + private System.Windows.Forms.Label labelAgentName; + private System.Windows.Forms.TextBox agentName; + private System.Windows.Forms.Label labelAgentNameHint; private System.Windows.Forms.Label labelSubnets; private System.Windows.Forms.TextBox advertiseSubnets; private System.Windows.Forms.Label labelSubnetsHint; diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs index 304fe117e..577dc800a 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs @@ -22,6 +22,7 @@ public AgentTunnelDialog() public override bool ToProperties() { Runtime.Session[AgentProperties.AgentTunnelEnrollmentString] = enrollmentString.Text.Trim(); + Runtime.Session[AgentProperties.AgentTunnelAgentName] = agentName.Text.Trim(); Runtime.Session[AgentProperties.AgentTunnelAdvertiseSubnets] = advertiseSubnets.Text.Trim(); Runtime.Session[AgentProperties.AgentTunnelAdvertiseDomains] = advertiseDomains.Text.Trim(); Runtime.Session[AgentProperties.AgentTunnelGatewayUrl] = gatewayUrl.Text.Trim(); @@ -34,6 +35,7 @@ public override void OnLoad(object sender, EventArgs e) banner.Image = Runtime.Session.GetResourceBitmap("WixUI_Bmp_Banner"); enrollmentString.Text = Runtime.Session.Property(AgentProperties.AgentTunnelEnrollmentString); + agentName.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAgentName); advertiseSubnets.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseSubnets); advertiseDomains.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseDomains); gatewayUrl.Text = Runtime.Session.Property(AgentProperties.AgentTunnelGatewayUrl); @@ -43,10 +45,12 @@ public override void OnLoad(object sender, EventArgs e) public override bool DoValidate() { - // Tunnel is optional — if enrollment string is empty, skip tunnel setup entirely. + // The dialog is only reached when the Agent Tunnel feature is selected (see Wizard.ShouldSkip), + // so an enrollment string is required at this point. if (string.IsNullOrWhiteSpace(enrollmentString.Text)) { - return true; + ShowValidationErrorString("Enrollment string is required. Paste a JWT from Devolutions Server, Hub, or Gateway, or go back and deselect the Agent Tunnel feature."); + return false; } // JWT shape: three base64url segments separated by dots. The agent's `up --enrollment-string` diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 14b412519..c9e4ad964 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -349,6 +349,14 @@ static void Main() // - Make DevolutionsDesktopAgent answer WM_CLOSE projectProperties.Add(new Property("MSIRESTARTMANAGERCONTROL", "Disable")); + // Agent tunnel properties: must be declared Secure so the values set in the wizard UI + // survive the UAC boundary and reach the deferred CA via CustomActionData. + projectProperties.Add(new Property(AgentProperties.AgentTunnelEnrollmentString, "") { Hidden = true, Secure = true }); + projectProperties.Add(new Property(AgentProperties.AgentTunnelGatewayUrl, "") { Secure = true }); + projectProperties.Add(new Property(AgentProperties.AgentTunnelAgentName, "") { Secure = true }); + projectProperties.Add(new Property(AgentProperties.AgentTunnelAdvertiseSubnets, "") { Secure = true }); + projectProperties.Add(new Property(AgentProperties.AgentTunnelAdvertiseDomains, "") { Secure = true }); + project.Properties = projectProperties.ToArray(); project.ManagedUI = new ManagedUI(); project.ManagedUI.InstallDialogs.AddRange(Wizard.Dialogs); diff --git a/package/AgentWindowsManaged/Properties/AgentProperties.cs b/package/AgentWindowsManaged/Properties/AgentProperties.cs index 4aa6365f3..b8facf3e4 100644 --- a/package/AgentWindowsManaged/Properties/AgentProperties.cs +++ b/package/AgentWindowsManaged/Properties/AgentProperties.cs @@ -38,6 +38,12 @@ internal partial class AgentProperties /// public static string AgentTunnelGatewayUrl = "AGENT_TUNNEL_GATEWAY_URL"; + /// + /// Optional agent display name. Resolution order at install time: + /// dialog value (if non-empty) > JWT's jet_agent_name claim (if present) > local computer name. + /// + public static string AgentTunnelAgentName = "AGENT_TUNNEL_AGENT_NAME"; + public AgentProperties(ISession runtimeSession) { this.runtimeSession = runtimeSession; diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl index 1c4e67813..5a97175d2 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl @@ -67,6 +67,8 @@ If it appears minimized then active it from the taskbar. Comma-separated CIDR notation, e.g. 10.10.0.0/24, 192.168.1.0/24. Leave blank for auto-detection. Advertise Domains: Comma-separated DNS suffixes the agent can resolve, e.g. corp.example.com, lab.example.com. Leave blank to skip. + Agent name (optional): + Identifier for this agent. Leave blank to use the name in the JWT, or the local computer name as a final fallback. Gateway URL (advanced, optional): Override the URL embedded in the enrollment JWT. Leave blank to use the JWT's value. diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl index 791721572..9fbd7d361 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl @@ -12,6 +12,8 @@ Notation CIDR séparée par des virgules, p. ex. 10.10.0.0/24, 192.168.1.0/24. Laissez vide pour la détection automatique. Domaines annoncés : Suffixes DNS séparés par des virgules que l'agent peut résoudre, p. ex. corp.example.com, lab.example.com. Laissez vide pour ignorer. + Nom de l'agent (facultatif) : + Identifiant de cet agent. Laissez vide pour utiliser le nom inscrit dans le JWT, ou le nom de l'ordinateur local comme dernier recours. URL de la passerelle (avancé, facultatif) : Remplace l'URL incluse dans le JWT d'enrôlement. Laissez vide pour utiliser la valeur du JWT. 1036 From 2185192f2992eb0819dba4b6bee9ef983077e7b2 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 16:22:57 -0400 Subject: [PATCH 6/6] docs: agent tunnel gateway identity & endpoint resolution design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the root cause behind silent enrollment-success-but-no-tunnel failures we hit during integration testing, the constraints we've confirmed with the team, and the proposed redesign: - Decouple Gateway's cryptographic identity (server cert SAN) from its network reachability (the host agents dial). Replace single conf.hostname with AgentTunnel.AdvertisedNames (multi-SAN, label-able). - Agent derives its QUIC endpoint from the host it enrolled through (jet_gw_url) + a quic_port returned by the gateway, instead of accepting whatever hostname the gateway dictates. - Gateway validates enrollment URL host against AdvertisedNames upfront, with a structured 400 response carrying error/message/help. - New agent.exe verify-tunnel subcommand wired into the MSI CA so install success means the tunnel is actually up, not just that a cert was written. Errors expose a structured kind/detail/next_step triple. - DVLS enrollment-string UI becomes a dropdown over AdvertisedNames (refreshed from gateway diagnostics) instead of a free-text URL box. Includes a 9-entry error catalog with operator-facing next-step text, non-goals (single-use enforcement, gateway farms — deferred), migration path, and a 5-PR implementation plan. Includes Codex's review. --- AGENT_TUNNEL_IDENTITY_DESIGN.md | 536 ++++++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 AGENT_TUNNEL_IDENTITY_DESIGN.md diff --git a/AGENT_TUNNEL_IDENTITY_DESIGN.md b/AGENT_TUNNEL_IDENTITY_DESIGN.md new file mode 100644 index 000000000..521b3ba0e --- /dev/null +++ b/AGENT_TUNNEL_IDENTITY_DESIGN.md @@ -0,0 +1,536 @@ +# Agent Tunnel: Gateway Identity & Endpoint Resolution + +Design proposal for decoupling Gateway's cryptographic identity (server cert SAN) +from its network reachability (the endpoint agents dial). + +Audience: gateway/agent/installer maintainers + DVLS-side maintainers. + +## Background + +The PR (#1789) introduces an agent tunnel where: + +- DVLS mints an enrollment JWT with `jet_gw_url` claim +- Agent POSTs CSR to `/jet/tunnel/enroll` +- Gateway signs the CSR with its internal `agent-tunnel-ca`, returns: + - `client_cert_pem`, `gateway_ca_cert_pem`, `server_spki_sha256` + - `quic_endpoint`: `format!("{}:{}", conf.hostname, conf.agent_tunnel.listen_port)` +- Agent stores everything in `agent.json` and connects QUIC to that endpoint + +## Problem + +A single `conf.hostname` field on the gateway side is overloaded as both: + +1. **Cryptographic identity** — the SAN written into `agent-tunnel-server-cert.pem` +2. **Network advertisement** — the hostname returned to agents as their dial target + +These two responsibilities are coupled in code but uncoupled in reality. + +In any realistic deployment a single gateway is reachable through multiple +distinct names depending on the agent's network position: + +- HQ-internal FQDN (`gateway.corp.example.com`) +- LAN/lab IP literal (`10.10.0.7`) +- External public DNS (`agw.public.example.com`) + +Today the gateway picks one (`conf.hostname`) and forces every agent to use it, +regardless of how the agent was told to reach the gateway by the admin. + +### Symptom we hit during integration testing + +- Gateway config: `Hostname = "it-help-gw.ad.it-help.ninja"` +- Enrollment JWT: `jet_gw_url = "http://10.10.0.7:7777"` (chosen because the + test VM cannot resolve internal AD DNS) +- Agent enrolls successfully via IP +- Gateway response: `quic_endpoint = "it-help-gw.ad.it-help.ninja:4433"` +- Agent service tries to QUIC-dial that hostname, DNS resolution fails forever, + silent reconnect loop + +The installer reports success because `agent.exe up` exits 0 (it writes +config; it does not validate the resulting tunnel). + +### Why this is a design issue, not just a config oversight + +The admin had no leverage: they correctly used an IP because the agent's +network couldn't see the gateway by name. The gateway then overrode that +choice and asserted a name the agent's network had never heard of. The +admin's intent ("reach me at 10.10.0.7") was lost the moment enrollment +moved past the HTTP request. + +If we "fix" by switching `Hostname` to the IP, we break the AD-internal use +case where agents do use the FQDN. The fields fight each other because they +should not be the same field. + +## Constraints (confirmed) + +| Question | Answer | +|---|---| +| Single gateway reachable via multiple names? | Very common | +| Multiple DVLS instances? | 99% single, but design must not assume | +| Agent roams between gateways? | No, one gateway per agent for life | +| Enrollment token reuse? | Reusable until token expiry for this iteration; TODO: decide single-use enforcement later | +| Reinstall reuses identity? | No, always new agent_id | + +## Proposed design + +### 1. Gateway: multi-SAN server cert + advertised-names list + +Add a new config block: + +```json +"AgentTunnel": { + "Enabled": true, + "ListenPort": 4433, + "AdvertisedNames": [ + "gateway.corp.example.com", + "10.10.0.7", + "agw.public.example.com" + ] +} +``` + +`AdvertisedNames` accepts either a bare string or an object with a display +label, deserialized via serde `#[serde(untagged)]`: + +```json +"AdvertisedNames": [ + "10.10.0.7", + { "name": "gateway.corp.example.com", "label": "HQ FQDN" }, + { "name": "agw.public.example.com", "label": "Public DNS" } +] +``` + +The label is purely informational, surfaced by DVLS UI when offering the +admin a choice. The Gateway itself only uses `name` for SAN generation and +host validation. + +`AdvertisedNames` is the authoritative list of names/IPs this gateway is +reachable as for the agent-tunnel use case. The gateway: + +- Signs `agent-tunnel-server-cert.pem` with **all** of them in SAN + (DnsName entries for FQDNs, IpAddr entries for literals — rcgen handles + both) +- Regenerates the cert at startup whenever the SAN set on disk differs from + the current config list (allow admin to add/remove names without a manual + cert reset). SAN-only regeneration must reuse the existing + `agent-tunnel-server-key.pem` so the server SPKI pin remains stable for + already enrolled agents. Generate a new server key only when the key is + missing/corrupt; that is an SPKI rotation event and existing agents must be + re-enrolled. +- Exposes `AdvertisedNames` by **extending the existing diagnostics endpoint** + (`/jet/diagnostics/configuration`) rather than adding a new route. The + response gains an `agent_tunnel` field carrying `enabled`, `listen_port`, + and `advertised_names`. Same auth scope, no new public surface. DVLS reads + this single endpoint for all gateway introspection. + +The legacy `conf.hostname` is no longer used for the agent tunnel cert. It +remains usable elsewhere (or we deprecate it in a follow-up). + +### 2. Enrollment response: compatibility bridge + +Today: + +```rust +let quic_endpoint = format!("{}:{}", conf.hostname, conf.agent_tunnel.listen_port); +``` + +For one compatibility window, return both the legacy full endpoint and the new +port-only field: + +```rust +pub struct EnrollResponse { + pub agent_id: Uuid, + pub client_cert_pem: String, + pub gateway_ca_cert_pem: String, + pub quic_endpoint: String, // legacy: ":" + pub quic_port: u16, + pub server_spki_sha256: String, +} +``` + +`quic_endpoint` must be computed from the normalized `jet_gw_url` host and the +agent tunnel listen port, not from `conf.hostname`. +Old agents keep using `quic_endpoint` and still benefit from the fix. +New agents prefer `quic_port` plus the enrollment URL host. +After one release, remove `quic_endpoint` in a schema cleanup PR. + +The long-term model is: the gateway tells the agent which port to dial, not +which host. The host the agent uses is whichever host the agent already chose +to enroll through — that's the host the admin intentionally configured for +that agent's network. + +### 3. Agent: derive the endpoint from the enrollment URL + +In `devolutions-agent`: + +```rust +// JWT carries jet_gw_url, e.g. "https://10.10.0.7:7777" +let enrollment_url = Url::parse(&claims.jet_gw_url)?; +let host = enrollment_url.host_str().context("missing host in jet_gw_url")?; + +// Response carries quic_port; helper handles DNS, IPv4, and bracketed IPv6. +let gateway_endpoint = format_endpoint(host, quic_port)?; +``` + +This goes into `agent.json` as `Tunnel.GatewayEndpoint`. On runtime the agent: + +- Resolves `host` (which the admin already verified is resolvable from this + agent's network, by virtue of the enrollment URL working) +- QUIC-dials it +- TLS handshake uses `host` as SNI; the server cert SAN list includes `host` + (because admin put it in `AdvertisedNames`); validation passes +- SPKI pinning still applies on top + +Endpoint formatting must not be raw `format!("{host}:{port}")`. +It must handle: + +| host kind | endpoint | +|---|---| +| DNS | `gateway.example.com:4433` | +| IPv4 | `10.10.0.7:4433` | +| IPv6 | `[fd00::7]:4433` | + +Host comparison and SAN generation must normalize DNS names case-insensitively +and parse IP literals as IP addresses rather than DNS names. + +### 4. Gateway: validate the enrollment URL host against AdvertisedNames + +Before signing the CSR, gateway parses the JWT's `jet_gw_url` and rejects +the request if the host portion is not in `AdvertisedNames`. This fails fast +with a clear error message instead of producing a cert/endpoint pair the +agent cannot use. + +Response on rejection (HTTP 400): + +```json +{ + "error": "enrollment_host_not_advertised", + "message": "The Gateway is not advertised as 'evil.example.com'. Allowed advertised names: [\"gateway.corp.example.com\", \"10.10.0.7\"].", + "help": "Either (a) regenerate the enrollment string in DVLS using one of the names listed above, or (b) ask the Gateway operator to add 'evil.example.com' to AgentTunnel.AdvertisedNames in gateway.json and restart the Gateway." +} +``` + +The HTTP body is consumed by the agent CLI and re-emitted to stderr verbatim +so the message reaches the installer dialog and Windows event log. + +### 5. Enrollment token replay prevention (TODO, deferred) + +Do **not** add a gateway-side enrollment/JTI store in this pass. +Enrollment JWTs remain reusable until their normal expiry. + +Strict single-use enrollment is still desirable, but it should be handled as +a follow-up decision rather than bundled into the endpoint identity fix. +The preferred owner is DVLS because DVLS issues the enrollment JWT and presents +the enrollment string to the admin. +If Gateway later needs to enforce replay prevention independently, the design +can revisit a bounded consumed-JTI store as an explicit statefulness tradeoff. + +### 6. Installer: verify-then-report + +Add `agent.exe verify-tunnel --timeout `. It: + +- Reads `agent.json` +- Performs one QUIC handshake +- Sends one `RouteAdvertise` message and waits for ack +- Exit code 0 = tunnel works +- Non-zero = exits with stderr describing the failure point AND a one-line + next-step the operator can act on without reading source + +In `CA.EnrollAgentTunnel`, after `up` returns success, call `verify-tunnel` +with a hardcoded 10s timeout. If it fails, `ActionResult.Failure` + +InstallMessage.Error + MSI rollback. The installer's "success" now means +the tunnel is up, not just that a cert exists. + +The 10s timeout is not configurable in this iteration. No MSI property to +tune it, no escape hatch to skip verification. If real deployments later +need a longer budget for slow customer networks, expose a property then — +not pre-emptively. + +`verify-tunnel`'s stderr is a **single line of JSON** carrying the error +triple, written as the last line before exit: + +``` +{"kind":"dns_resolution_failed","detail":"Could not resolve 'gateway.corp' from this machine","next_step":"This agent's network cannot resolve 'gateway.corp'. ..."} +``` + +The installer CA reads stderr, parses the JSON, and feeds `kind`, `detail`, +`next_step` into the MSI error dialog. Agent log file and Windows Event Log +record the same object plus underlying stack. + +Drop the Gateway URL override field from the installer dialog — with this +design the JWT is the single source of truth for the agent-facing URL, and +overriding it server-side would defeat the whole point. + +#### Error catalog (verify-tunnel + agent service runtime) + +Every failure path must emit a structured triple: **kind**, **detail**, +**next_step**. The installer dialog and Windows Event Log show all three; +the agent log file shows them plus the underlying stack. + +| kind | when it fires | detail (variable) | next_step (the help text) | +|---|---|---|---| +| `enrollment_host_not_advertised` | Gateway rejects enrollment at HTTP layer (Section 4) | "Gateway advertises: [...]. JWT used host: X" | "Regenerate the enrollment string in DVLS using one of the advertised names, or add 'X' to AgentTunnel.AdvertisedNames on the Gateway." | +| `dns_resolution_failed` | QUIC dial step, OS returns NXDOMAIN / no such host | "Could not resolve 'X' from this machine" | "This agent's network cannot resolve 'X'. Either generate an enrollment string with a name this machine can resolve (e.g. an IP literal that the Gateway also advertises), or add a DNS entry / hosts file mapping for 'X'." | +| `udp_unreachable` | DNS resolves but UDP socket cannot send / no QUIC initial response in N seconds | "Resolved X -> A.B.C.D; UDP/ blocked or no listener" | "Verify Gateway is running and UDP is open between this agent and the Gateway. Check Windows Firewall, corporate firewall, NAT, and SophosNTP / EDR network filters on both ends." | +| `tls_san_mismatch` | QUIC TLS handshake fails because server cert SAN does not include the dial host | "Connecting as 'X' but server cert SAN is [...]" | "Gateway operator must add 'X' to AgentTunnel.AdvertisedNames in gateway.json and restart the Gateway. The server certificate will be regenerated with X in SAN." | +| `tls_spki_pin_mismatch` | TLS chain validates but SPKI does not match the value captured at enrollment | "Pinned SPKI ; server presented SPKI " | "The Gateway's agent-tunnel keypair changed since this agent enrolled (server key regenerated, gateway reinstalled, or man-in-the-middle). Re-enroll this agent by uninstalling and reinstalling with a fresh enrollment string." | +| `quic_handshake_timeout` | TLS got far enough to start but no Finished message within timeout | "Handshake stalled at " | "Network path likely drops UDP mid-flow (path MTU, broken NAT, deep packet inspection). Try a different network egress, lower QUIC MTU, or disable EDR network inspection for the Gateway endpoint." | +| `route_advertise_timeout` | Tunnel up but Gateway did not ack RouteAdvertise within timeout | "QUIC connected, no advertise ack in s" | "Gateway is running an older or incompatible build; ensure Gateway version supports the agent tunnel feature. Check Gateway logs for RouteAdvertise handling errors." | +| `enrollment_token_expired` | JWT exp claim is in the past | "exp: , now: " | "Generate a new enrollment string in DVLS. Default token lifetime is short; coordinate enrollment with the installer run." | +| `enrollment_token_signature_invalid` | JWT signature does not verify against provisioner.pem | "verification error: " | "The Gateway's provisioner.pem does not match the DVLS instance that signed this enrollment string. Verify DVLS is configured with the same Gateway entry, and that provisioner.pem on the Gateway corresponds to the provisioner.key DVLS is using." | +| `unexpected_error` | A failure path has not yet been classified | "Unexpected failure during ; correlation_id=; log=" | "Collect the agent log and Gateway log using the correlation ID, then file a support issue. This is a product bug if it reaches the operator." | + +#### Surface points + +- **Installer dialog**: shows `kind` as the error title, `detail` as the + subtitle, `next_step` as the body. One Copy-to-Clipboard button copies all + three plus the timestamp and agent ID (if assigned). +- **Windows Event Log**: source = "DevolutionsAgent"; one event per failure + with the structured fields as named properties so it's parseable by + monitoring tools. +- **Agent service log file** (`agent..log`): full triple plus + underlying stack and the request/response payloads (redacted). +- **DVLS Agent list view**: when an agent shows offline, the per-row tooltip + shows the most recent `kind` + `next_step` so the admin sees the actionable + hint without leaving the UI. + +#### Anti-goals for the error catalog + +- No bare "unknown error", "internal error", or other context-free catch-all + messages reach the operator. The fallback is `unexpected_error`, and it must + include `detail`, `next_step`, correlation ID, and log location. +- No stack traces in the operator-facing surface. Stacks live in agent log + files only. +- No URLs to docs as the sole answer. The `next_step` must be self-contained + for the common case. Docs links are additive. + +### 7. DVLS + +- When admin adds a Gateway entry, DVLS fetches `AdvertisedNames` from + `/jet/diagnostics/agent-tunnel` and stores them as a cache +- When generating an enrollment string, DVLS refreshes `AdvertisedNames` from + the Gateway before presenting choices. A stale cached list must not be the + only source for new enrollment strings. +- "Generate enrollment string" UI presents `AdvertisedNames` as a dropdown + instead of a free-text URL field +- Agent list view queries the gateway's `/jet/tunnel/agents` for live status + rather than maintaining a separate DVLS-side mirror + +## Migration + +- Existing deployments with `conf.hostname = "x"` and no `AdvertisedNames`: + default `AdvertisedNames = [conf.hostname]` so single-name setups keep + working without changes +- Existing agent.json files with the old `GatewayEndpoint` string remain + valid; nothing to migrate +- Existing enrollment tokens remain reusable until expiry. Strict single-use + replay prevention is a TODO and is not part of this change. + +## Resolved decisions + +| # | Question | Decision | +|---|---|---| +| 1 | Cert regen trigger | Silent at startup. Log previous SAN, new SAN, new cert fingerprint. | +| 2 | Verify-tunnel timeout | Hardcoded 10s. No MSI property. No skip-verify escape hatch. | +| 3 | AdvertisedNames discovery | Extend `/jet/diagnostics/configuration` with an `agent_tunnel` field. Same scope. No new endpoint. | +| 4 | Error triple transport | Single-line JSON on stderr. Installer CA parses and surfaces fields into InstallMessage.Error. | +| 5 | Compat bridge | `EnrollResponse` returns both `quic_endpoint` (legacy, computed from `jet_gw_url.host`) and `quic_port` (new). Remove `quic_endpoint` in a follow-up release. | +| 6 | AdvertisedNames schema | Accept string or `{name, label}` object via serde untagged. Label is informational, surfaced by DVLS UI. | + +## Explicit non-goals (deferred to follow-up PRs) + +- **Single-use enrollment enforcement**. Tokens reusable until expiry for + this iteration. Future decision: DVLS, Gateway, or both as owner. +- **Gateway farm / load-balanced gateway HA**. Agent tunnel assumes one + agent enrolls to one gateway for life. A shared FQDN across multiple + gateway backends behind a load balancer is not supported in this + iteration. An agent enrolled through such an LB may bind to a single + backend via session affinity, but cross-gateway agent discovery is not + part of this design. Document this in admin docs. +- **Configurable verify-tunnel timeout / skip-verify escape hatch**. Add + later if real deployments demand it; not pre-emptively. + +## Implementation plan (PR breakdown) + +Each PR ships independently. PR 1 alone fixes the SAN mismatch; subsequent +PRs add the polish. + +### PR 1 — Gateway: AdvertisedNames + multi-SAN + diagnostics + host validation + +Scope (all in `devolutions-gateway`): + +- Add `AgentTunnelConf.advertised_names: Vec` with serde + untagged string-or-object support. +- Migration shim: when absent, default to `vec![conf.hostname.clone()]` so + existing deployments keep working. +- At gateway boot: compare on-disk `agent-tunnel-server-cert.pem` SAN list + against config. If different, regenerate cert (reusing existing keypair) + with all advertised names as multi-SAN. Log old SAN, new SAN, new cert + fingerprint. +- `EnrollResponse`: add `quic_port: u16`. Compute `quic_endpoint` from + the validated `jet_gw_url.host` + agent tunnel listen port (not from + `conf.hostname`). +- Enrollment handler: parse `jet_gw_url`, normalize host (DNS lowercased, + IPs parsed), reject with HTTP 400 + structured `{error, message, help}` + body when host is not in `AdvertisedNames`. +- Extend `/jet/diagnostics/configuration` response with `agent_tunnel: + { enabled, listen_port, advertised_names: [{ name, label }] }`. + +Verification: + +- Unit tests for SAN regen idempotence, host normalization, host + validation. +- Integration test: configure gateway with `AdvertisedNames = [name1, + name2]`; enroll via name1; verify cert SAN contains both; reject + enrollment via name3. + +### PR 2 — Agent: derive endpoint from JWT host, consume `quic_port` + +Scope (all in `devolutions-agent`): + +- Parse `jet_gw_url` host from JWT. +- `format_endpoint(host, port)` helper handling DNS / IPv4 / bracketed + IPv6. +- Prefer `quic_port` from response when available; fall back to parsing + `quic_endpoint` for backward compatibility against older gateways. +- Write `agent.json::Tunnel.GatewayEndpoint` from the new logic. + +Verification: + +- Unit tests for endpoint formatting (IPv4, IPv6, DNS). +- End-to-end: agent enrolls via IP literal, QUIC dials same IP, TLS SAN + validates against multi-SAN cert from PR 1. + +### PR 3 — Installer: `verify-tunnel` + structured error surfacing + +Scope (split across `devolutions-agent` and `dgw-pr-installer/package/AgentWindowsManaged`): + +- New `agent.exe verify-tunnel --timeout ` subcommand. One QUIC + handshake + one RouteAdvertise round-trip. Emits single-line JSON triple + on stderr; exit code 0 on success, non-zero on failure. +- Error catalog implementation (kinds from Section 6 of this doc). +- `CA.EnrollAgentTunnel` calls `verify-tunnel` after `up`. Parses stderr + JSON; on failure, `ActionResult.Failure` + `session.Message(Error, ...)`. +- Drop Gateway URL override field from `AgentTunnelDialog` (and the + associated `AGENT_TUNNEL_GATEWAY_URL` Property declaration). +- Windows Event Log writer in agent service for the same triples (source: + `DevolutionsAgent`, structured named properties). + +Verification: + +- Manual install with bad enrollment (DNS unresolvable) → installer + dialog shows `next_step`, MSI rollbacks. +- Manual install with good enrollment → tunnel up, installer reports + success, agent appears in gateway agents list. + +### PR 4 — DVLS: AdvertisedNames dropdown + live agent list + +Scope (DVLS Web + DVLS server): + +- Gateway entry editor: on save, call gateway's + `/jet/diagnostics/configuration`, store `agent_tunnel.advertised_names` + in the gateway record. +- "Generate enrollment string" UI: dropdown of advertised names with + labels, no free-text URL box. Refresh from gateway before generation. +- Agent list view: query gateway's `/jet/tunnel/agents` for live status + instead of mirroring locally. Tooltip shows latest `kind` + `next_step` + for offline agents. + +Verification: + +- Add a new advertised name in gateway.json → DVLS sees it after manual + refresh + on next "Generate" click. +- Generate string → install agent → DVLS list shows agent online within + 30 seconds. + +### PR 5 (future) — Single-use enforcement, gateway farm story + +Out of scope for the identity refactor. Tracked as follow-ups. + +## What this design does NOT change + +- Trust chain: provisioner key still lives only in DVLS; gateway has only + the public half. Agent-tunnel CA still lives only in gateway. +- Cert pinning: SPKI pin still applies on top of SAN check. +- One-gateway-per-agent invariant. +- Reinstall semantics (always a new agent_id). + +## Codex opinion - 2026-05-22 + +I reviewed this against the local knowledge base before commenting, especially `D:\AGENT_KNOWLEDGE_BASE\integrations\dvls-to-gateway-agent-tunnel.md`, `D:\AGENT_KNOWLEDGE_BASE\integrations\gateway-quic-tunnel-pr-split.md`, `D:\AGENT_KNOWLEDGE_BASE\integrations\how-they-fit-together.md`, `D:\AGENT_KNOWLEDGE_BASE\projects\devolutions-gateway.md`, `D:\AGENT_KNOWLEDGE_BASE\projects\DVLS.md`, and `D:\AGENT_KNOWLEDGE_BASE\notes\tokens-and-claims.md`. +My short take is: the core design is right, but I would ship it with a compatibility bridge and be careful not to undo the current stateless DVLS-signed enrollment direction. + +The important product problem is not certificate generation. +The product problem is that the installer can say "success" while the tunnel is dead because the agent was handed an endpoint it cannot resolve. +For IT teams and MSPs, that failure mode is expensive because it appears after deployment, often on a remote customer network, and it turns a clean RMM or MSI rollout into a support ticket. +Fixing this makes Agent Tunnel feel like a real deployment feature instead of a lab feature. + +The business value is strong. +MSPs live in split-DNS, NAT, VPN, customer-site, and segmented-network reality. +They need to enroll agents from whatever name or IP works at that site, then let RDM and DVLS route RDP, SSH, KDC, and other Gateway traffic through that agent without opening inbound firewall holes to every target. +This feature reduces customer network friction, makes private-network onboarding more repeatable, and gives Devolutions a cleaner story for managed remote access into customer environments. + +The expected user workflow should be simple. +An admin configures the Gateway with the names or IPs that agents may legitimately use. +DVLS shows those choices when generating the enrollment string. +The admin chooses the endpoint that matches the target network, then deploys the Agent MSI through RMM, GPO, Intune, or manual install. +The installer enrolls, verifies one real tunnel handshake, and only reports success if the tunnel can actually advertise routes. +After that, help desk users and administrators should not have to think about the tunnel when launching sessions from RDM or DVLS Web. + +I strongly agree with splitting cryptographic identity from network reachability. +`conf.hostname` should not be both the SAN source and the agent dial target. +The multi-SAN `AdvertisedNames` model is the right primitive because the Gateway can be known as an internal FQDN, a public DNS name, and a site-local IP at the same time. +The config name might be worth refining to something like `AgentTunnel.AdvertisedHosts` or `AgentTunnel.ReachableNames`, but the concept is correct. + +I also agree that the agent should derive the QUIC host from the enrollment URL host. +If the agent successfully reached `jet_gw_url` during enrollment, that host is the best available evidence of what works from the agent's network. +The gateway should return the QUIC port, not override the host with `conf.hostname`. +Implementation must handle IP literals and IPv6 bracket formatting carefully, because `10.10.0.7:4433` and `[fd00::7]:4433` need different endpoint formatting. + +The main compatibility risk is the enrollment response schema. +The 2026-05-21 KB snapshot says the current merged direction has the agent reading `jet_gw_url` from the enrollment JWT and still receiving `quic_endpoint` from the enrollment response. +I would not hard-break that response unless every dependent artifact is moved in one coordinated PR set. +For one release, I would accept both shapes or return both `quic_endpoint` and `quic_port`, with the new agent preferring `quic_port` plus the enrollment URL host. +That keeps older agents and installer builds from failing during staged rollout. + +I agree with validating the enrollment URL host against `AdvertisedNames`. +That validation should happen as early as possible and produce an operator-grade error, not a generic enrollment failure. +DNS names should compare case-insensitively, IPs should be parsed and normalized, and the implementation should avoid accepting an arbitrary redirected host just because the HTTP request reached the gateway. +The security property should be: the agent may only enroll through a host or IP the Gateway operator intentionally advertised for agent tunnel use. + +The `verify-tunnel` installer step is a must-have, not a nice-to-have. +Without it, we still have a gap between "configuration was written" and "the customer can route a session". +A 10 second default is reasonable, but the MSI property should be overrideable for slow customer networks. +The error should identify the failing phase: DNS, UDP reachability, TLS SAN validation, SPKI pinning, QUIC handshake, route advertise, or timeout. + +I am more cautious about the consumed-JTI SQLite table, and the current decision is to defer it. +Single-use enrollment is good security, but the KB says the architecture intentionally moved to stateless DVLS-signed JWT enrollment and removed gateway-side enrollment token storage. +For this iteration, enrollment tokens can remain reusable until expiry. +If strict single-use is required later, the cleanest owner is the issuer, which is DVLS, because DVLS is generating the enrollment string and presenting it to the admin. +If the Gateway must enforce replay prevention anyway, the table is acceptable as a bounded fallback, but it should be called out as a deliberate tradeoff against stateless enrollment rather than a small implementation detail. + +The DVLS UI should not be a free-text URL box for normal users. +The dropdown from `AdvertisedNames` is the right default because it prevents typos and keeps the cert SAN list, gateway validation, and admin intent aligned. +For MSP usability, each advertised name should probably have an optional display label such as "Customer LAN", "Public DNS", or "Lab subnet". +Most IT operators think in site and network names first, not in certificate SAN mechanics. + +Certificate regeneration at startup is acceptable if it is explicit in logs and diagnostics. +I would log the previous SAN set, new SAN set, and new server certificate fingerprint. +DVLS should also be able to detect drift by querying diagnostics, because otherwise an admin can generate an enrollment string using a stale cached name after the Gateway config changed. +This is especially important for MSPs managing multiple customer gateways. + +The load balancer and gateway-farm question remains the biggest unresolved edge. +The design correctly acknowledges that a shared FQDN can route an enrolled agent to the wrong gateway if registration state is per-gateway. +We should not accidentally imply HA support for agent tunnel until there is sticky routing, shared agent registry state, or a documented farm ownership model. +For now, the UI and docs should describe this as one agent enrolled to one gateway, with load-balanced gateway farms out of scope. + +My recommended implementation order would be: + +1. Add `AgentTunnel.AdvertisedNames`, multi-SAN server cert generation, diagnostics exposure, and enrollment-host validation. +2. Change new agents to derive the QUIC host from `jet_gw_url` and consume `quic_port`, while keeping response compatibility for old agents during rollout. +3. Add `agent.exe verify-tunnel` and wire the MSI to fail install when verification fails. +4. Update DVLS to present advertised names as labeled choices when generating enrollment strings. +5. Revisit strict single-use enforcement and gateway-farm behavior as explicit TODO follow-up decisions. + +Bottom line: I would move forward with this design. +It solves a real deployment blocker, lines up with how IT professionals and MSPs actually operate, and turns enrollment from "certs were written" into "the tunnel is reachable and usable". +The only part I would not take blindly is the gateway-side JTI store, because it partially reverses the stateless enrollment architecture that the current PR stack just landed.