From a8725701d276f13875e2faceb4f09aad6d519f72 Mon Sep 17 00:00:00 2001 From: irving ou Date: Wed, 20 May 2026 16:37:25 -0400 Subject: [PATCH 01/11] 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 21dd17d9d60a2e34fccba1c0c772cd4cdbd7b6fa Mon Sep 17 00:00:00 2001 From: irving ou Date: Thu, 21 May 2026 14:26:06 -0400 Subject: [PATCH 02/11] 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 ba03c0d07d32a7ff0714e8ea925035cf1fa67616 Mon Sep 17 00:00:00 2001 From: irving ou Date: Thu, 21 May 2026 15:21:45 -0400 Subject: [PATCH 03/11] 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 698b6e3fec670e78077fa01cbff62e716aa0ea61 Mon Sep 17 00:00:00 2001 From: irving ou Date: Thu, 21 May 2026 16:54:44 -0400 Subject: [PATCH 04/11] 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 1fde5e4894cb9377dcae8f31511e9b2add624536 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 16:22:41 -0400 Subject: [PATCH 05/11] 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 3bf13ee6da4d8f915cda15c8d11395426c36883b Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 16:22:57 -0400 Subject: [PATCH 06/11] 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. From 2b13e59fddb5ca35ba32683dc5257062b48bd628 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 17:55:41 -0400 Subject: [PATCH 07/11] feat(agent): add verify-tunnel subcommand with structured error catalog Adds `agent.exe verify-tunnel --timeout ` which performs one QUIC handshake plus one RouteAdvertise + Heartbeat/HeartbeatAck round-trip and exits 0 on success or 1 on any classified failure. The last line of stderr is a single-line JSON triple `{kind, detail, next_step}` consumed by the installer custom action to surface actionable error dialogs. Implements the 9+1-kind error catalog from the design doc (section 6): enrollment_host_not_advertised, dns_resolution_failed, udp_unreachable, tls_san_mismatch, tls_spki_pin_mismatch, quic_handshake_timeout, route_advertise_timeout, enrollment_token_expired, enrollment_token_signature_invalid, and the unexpected_error catch-all which always carries a correlation_id and log path. On Windows the triple is also written to the Event Log under the DevolutionsAgent source with kind/detail/next_step as named properties so monitoring tools can parse failures without scraping text. --- devolutions-agent/Cargo.toml | 1 + devolutions-agent/src/lib.rs | 1 + devolutions-agent/src/main.rs | 93 +++ devolutions-agent/src/verify_tunnel.rs | 768 +++++++++++++++++++++++++ 4 files changed, 863 insertions(+) create mode 100644 devolutions-agent/src/verify_tunnel.rs diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index 4a09d0aff..a7e2ec2f9 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -85,6 +85,7 @@ features = [ "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Security", + "Win32_System_EventLog", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_Security_Cryptography", diff --git a/devolutions-agent/src/lib.rs b/devolutions-agent/src/lib.rs index 6363445c9..0790257e5 100644 --- a/devolutions-agent/src/lib.rs +++ b/devolutions-agent/src/lib.rs @@ -13,6 +13,7 @@ pub mod log; pub mod remote_desktop; pub mod tunnel; mod tunnel_helpers; +pub mod verify_tunnel; #[cfg(windows)] pub mod session_manager; diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index 71ca21580..859f31fca 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -142,6 +142,31 @@ fn parse_advertise_subnets(value: &str) -> Vec { .collect() } +/// Default verify-tunnel timeout (matches what the installer CA hardcodes). +const VERIFY_TUNNEL_DEFAULT_TIMEOUT_SECS: u64 = 10; + +/// Parse `verify-tunnel` CLI arguments. Currently supports a single `--timeout +/// ` flag and falls back to [`VERIFY_TUNNEL_DEFAULT_TIMEOUT_SECS`] when +/// absent. +fn parse_verify_tunnel_args(args: &[String]) -> Result { + let mut timeout_secs = VERIFY_TUNNEL_DEFAULT_TIMEOUT_SECS; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--timeout" => { + let value = parse_required_value(args, &mut index, "--timeout")?; + timeout_secs = value.parse::().context("--timeout must be a positive integer (seconds)")?; + if timeout_secs == 0 { + bail!("--timeout must be > 0"); + } + } + unexpected => bail!("unknown argument for verify-tunnel: {unexpected}"), + } + index += 1; + } + Ok(std::time::Duration::from_secs(timeout_secs)) +} + fn parse_up_command_args(args: &[String]) -> Result { let mut gateway_url = None; let mut enrollment_token = None; @@ -258,6 +283,50 @@ fn main() { } }); } + "verify-tunnel" => { + let args: Vec = env::args().skip(2).collect(); + let timeout = match parse_verify_tunnel_args(&args) { + Ok(timeout) => timeout, + Err(error) => { + eprintln!("[ERROR] Invalid verify-tunnel arguments: {error:#}"); + // Emit a structured unexpected_error triple so the installer CA still + // has something parseable on stderr. + let triple = devolutions_agent::verify_tunnel::ErrorTriple::new( + devolutions_agent::verify_tunnel::ErrorKind::UnexpectedError, + format!("verify-tunnel argument parse error: {error:#}"), + ); + triple.emit_to_stderr(); + std::process::exit(1); + } + }; + + let conf_handle = match ConfHandle::init() { + Ok(handle) => handle, + Err(error) => { + let triple = devolutions_agent::verify_tunnel::ErrorTriple::new( + devolutions_agent::verify_tunnel::ErrorKind::UnexpectedError, + format!("failed to load agent configuration: {error:#}"), + ); + triple.emit_to_stderr(); + std::process::exit(1); + } + }; + + let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime"); + let result = rt.block_on(devolutions_agent::verify_tunnel::verify_tunnel(&conf_handle, timeout)); + + match result { + Ok(()) => { + // Success path: nothing on stderr — installer CA only consumes stderr + // and absence of a JSON triple confirms the verification succeeded. + println!("verify-tunnel: tunnel is reachable and route-advertise round-trip ok"); + } + Err(triple) => { + triple.emit_to_stderr(); + std::process::exit(1); + } + } + } "up" => { let args: Vec = env::args().skip(2).collect(); let command = match parse_up_command_args(&args) { @@ -356,6 +425,30 @@ mod tests { ) } + #[test] + fn parse_verify_tunnel_defaults_to_10s() { + let timeout = parse_verify_tunnel_args(&[]).expect("parse empty"); + assert_eq!(timeout, std::time::Duration::from_secs(10)); + } + + #[test] + fn parse_verify_tunnel_explicit_timeout() { + let timeout = parse_verify_tunnel_args(&["--timeout".to_owned(), "30".to_owned()]).expect("parse"); + assert_eq!(timeout, std::time::Duration::from_secs(30)); + } + + #[test] + fn parse_verify_tunnel_rejects_zero_timeout() { + let err = parse_verify_tunnel_args(&["--timeout".to_owned(), "0".to_owned()]).expect_err("expect rejection"); + assert!(format!("{err:#}").contains("> 0"), "{err:#}"); + } + + #[test] + fn parse_verify_tunnel_rejects_unknown_flag() { + let err = parse_verify_tunnel_args(&["--bogus".to_owned()]).expect_err("expect rejection"); + assert!(format!("{err:#}").contains("--bogus"), "{err:#}"); + } + #[test] fn parse_up_command_args_accepts_enrollment_string() { let jwt = make_jwt(serde_json::json!({ diff --git a/devolutions-agent/src/verify_tunnel.rs b/devolutions-agent/src/verify_tunnel.rs new file mode 100644 index 000000000..95e5d2ac6 --- /dev/null +++ b/devolutions-agent/src/verify_tunnel.rs @@ -0,0 +1,768 @@ +//! One-shot verification of the agent's QUIC tunnel to the Gateway. +//! +//! This module powers `agent.exe verify-tunnel`. Unlike the long-running +//! [`tunnel::TunnelTask`], it performs a single QUIC handshake + a single +//! control-plane round-trip (`RouteAdvertise` followed by a `Heartbeat`/ +//! `HeartbeatAck` to prove the control stream is alive in both directions), +//! then exits. The exit code reports success/failure and the last line of +//! stderr is the JSON error triple defined in the design doc. +//! +//! ## Error catalog +//! +//! Every operator-reachable failure is classified into an [`ErrorKind`]. +//! Each kind carries a stable string identifier (`kind`), a variable-content +//! `detail`, and a fixed `next_step` describing what the operator should do. +//! There is exactly one catch-all (`unexpected_error`) that must carry a +//! correlation ID and the agent log file path. +//! +//! ## Output contract (installer-facing) +//! +//! - **stderr** receives a single JSON line as the very last thing written +//! before exit: +//! +//! ```text +//! {"kind":"dns_resolution_failed","detail":"...","next_step":"..."} +//! ``` +//! +//! - **stdout** is reserved for human-readable progress, but the installer +//! only consumes stderr. +//! +//! - **exit code** is `0` for success, `1` for any classified failure. + +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use agent_tunnel_proto::{ControlMessage, ControlStream, current_time_millis}; +use anyhow::Context as _; +use ipnetwork::Ipv4Network; +use serde::Serialize; +use sha2::Digest as _; + +use crate::config::ConfHandle; + +// --------------------------------------------------------------------------- +// Error catalog +// --------------------------------------------------------------------------- + +/// Stable identifier classes used by both the installer and the agent service. +/// +/// The `as_str` of each variant is the wire identifier emitted in the JSON +/// triple and must remain stable across releases — monitoring tools and the +/// MSI custom action key off these strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// Gateway HTTP-rejected enrollment because `jet_gw_url.host` was not in + /// `AgentTunnel.AdvertisedNames`. + EnrollmentHostNotAdvertised, + /// DNS lookup of the gateway endpoint hostname returned no records. + DnsResolutionFailed, + /// UDP/QUIC packets to the gateway received no response (firewall, NAT, + /// EDR network filter, or gateway not listening). + UdpUnreachable, + /// TLS handshake failed because the server certificate's SAN did not + /// include the dial host. + TlsSanMismatch, + /// TLS chain validated but the server's SPKI did not match the pin + /// captured at enrollment. + TlsSpkiPinMismatch, + /// QUIC handshake started but never reached the Finished state. + QuicHandshakeTimeout, + /// QUIC connected but the Gateway never acknowledged the control-plane + /// round-trip (`RouteAdvertise` + `Heartbeat`). + RouteAdvertiseTimeout, + /// The enrollment JWT's `exp` is in the past. + EnrollmentTokenExpired, + /// The enrollment JWT signature failed verification at the Gateway. + EnrollmentTokenSignatureInvalid, + /// Catch-all for unclassified failures. Must include a correlation ID + /// and the agent log path in `detail`. + UnexpectedError, +} + +impl ErrorKind { + pub fn as_str(self) -> &'static str { + match self { + ErrorKind::EnrollmentHostNotAdvertised => "enrollment_host_not_advertised", + ErrorKind::DnsResolutionFailed => "dns_resolution_failed", + ErrorKind::UdpUnreachable => "udp_unreachable", + ErrorKind::TlsSanMismatch => "tls_san_mismatch", + ErrorKind::TlsSpkiPinMismatch => "tls_spki_pin_mismatch", + ErrorKind::QuicHandshakeTimeout => "quic_handshake_timeout", + ErrorKind::RouteAdvertiseTimeout => "route_advertise_timeout", + ErrorKind::EnrollmentTokenExpired => "enrollment_token_expired", + ErrorKind::EnrollmentTokenSignatureInvalid => "enrollment_token_signature_invalid", + ErrorKind::UnexpectedError => "unexpected_error", + } + } + + /// Operator-facing next-step text. This is the fallback wording when the + /// caller does not provide a customized one (most call sites use this + /// default — only the host-not-advertised case needs to substitute the + /// actual host name into the help text). + pub fn next_step(self) -> &'static str { + match self { + ErrorKind::EnrollmentHostNotAdvertised => { + "Regenerate the enrollment string in DVLS using one of the advertised names, \ + or add the host used at enrollment to AgentTunnel.AdvertisedNames on the Gateway." + } + ErrorKind::DnsResolutionFailed => { + "This agent's network cannot resolve the configured gateway hostname. \ + 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 the hostname." + } + ErrorKind::UdpUnreachable => { + "Verify Gateway is running and the agent tunnel UDP port is open between this agent \ + and the Gateway. Check Windows Firewall, corporate firewall, NAT, and SophosNTP / \ + EDR network filters on both ends." + } + ErrorKind::TlsSanMismatch => { + "Gateway operator must add the dial host to AgentTunnel.AdvertisedNames in gateway.json \ + and restart the Gateway. The server certificate will be regenerated with the new host in SAN." + } + ErrorKind::TlsSpkiPinMismatch => { + "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." + } + ErrorKind::QuicHandshakeTimeout => { + "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." + } + ErrorKind::RouteAdvertiseTimeout => { + "Gateway is running an older or incompatible build; ensure Gateway version supports the agent \ + tunnel feature. Check Gateway logs for RouteAdvertise handling errors." + } + ErrorKind::EnrollmentTokenExpired => { + "Generate a new enrollment string in DVLS. Default token lifetime is short; coordinate enrollment \ + with the installer run." + } + ErrorKind::EnrollmentTokenSignatureInvalid => { + "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." + } + ErrorKind::UnexpectedError => { + "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." + } + } + } +} + +/// The error triple emitted to stderr as a single-line JSON object. +/// +/// Field ordering is fixed for greppability of installer logs: +/// `{"kind":..., "detail":..., "next_step":...}`. Serde preserves struct field +/// declaration order when serializing. +#[derive(Debug, Clone, Serialize)] +pub struct ErrorTriple { + pub kind: &'static str, + pub detail: String, + pub next_step: String, +} + +impl ErrorTriple { + /// Build a triple with the default `next_step` text from the catalog. + pub fn new(kind: ErrorKind, detail: impl Into) -> Self { + Self { + kind: kind.as_str(), + detail: detail.into(), + next_step: kind.next_step().to_owned(), + } + } + + /// Build a triple using a custom `next_step` text. Used for + /// host-not-advertised where the gateway returns a fully-formed help + /// string we should pass through verbatim. + pub fn with_next_step(kind: ErrorKind, detail: impl Into, next_step: impl Into) -> Self { + Self { + kind: kind.as_str(), + detail: detail.into(), + next_step: next_step.into(), + } + } + + /// Emit as a single line on stderr — the installer CA's contract. + pub fn emit_to_stderr(&self) { + // Single line, no trailing newline beyond the one `eprintln!` adds. + let line = serde_json::to_string(self).unwrap_or_else(|_| { + format!( + r#"{{"kind":"{}","detail":"","next_step":""}}"#, + self.kind, + ) + }); + eprintln!("{line}"); + } + + /// Emit to the Windows Event Log under source `DevolutionsAgent`. + /// + /// On non-Windows targets this is a no-op so the agent service callers + /// can use the same call site on every platform. + /// + /// The event source is registered by the installer's `WriteEventLog` / + /// `EventSource` table entries (or fabricated lazily at first write by + /// `RegisterEventSourceW`; if the source name is unknown, Windows logs to + /// the Application channel with a "description not found" suffix, still + /// readable for diagnosis). + pub fn emit_to_event_log(&self) { + #[cfg(windows)] + emit_event_log_windows(self); + #[cfg(not(windows))] + { + // Linux/macOS: agent service is Windows-only in this product, so + // there's nothing to write to. Caller still gets the stderr line + // and the tracing log entry. + let _ = self; + } + } +} + +#[cfg(windows)] +fn emit_event_log_windows(triple: &ErrorTriple) { + use windows::Win32::System::EventLog::{ + DeregisterEventSource, EVENTLOG_ERROR_TYPE, RegisterEventSourceW, ReportEventW, + }; + use windows::core::PCWSTR; + + // Build a NUL-terminated wide string for the source name. + let source: Vec = "DevolutionsAgent".encode_utf16().chain(std::iter::once(0)).collect(); + + // SAFETY: `source` is a NUL-terminated UTF-16 buffer; `RegisterEventSourceW` + // dereferences the pointer as a read-only PCWSTR. We hold `source` alive + // for the duration of the call. + let handle = unsafe { RegisterEventSourceW(PCWSTR::null(), PCWSTR(source.as_ptr())) }; + let Ok(handle) = handle else { + return; + }; + + // One wide line per named property — Event Viewer "Details (XML view)" then + // shows them as separate `` entries which monitoring tools + // can parse without scraping free-text. + let kind_line = format!("kind={}", triple.kind); + let detail_line = format!("detail={}", triple.detail); + let next_step_line = format!("next_step={}", triple.next_step); + + let strings: Vec> = [kind_line, detail_line, next_step_line] + .iter() + .map(|s| s.encode_utf16().chain(std::iter::once(0)).collect()) + .collect(); + let string_ptrs: Vec = strings.iter().map(|s| PCWSTR(s.as_ptr())).collect(); + + // EventID 1 = generic agent tunnel verification failure. Specific kinds + // are surfaced via the `kind=` property; we keep the EventID stable for + // monitoring tool filters. + let event_id: u32 = 1; + + // SAFETY: `handle` is a live event source handle obtained above; the + // string pointers are kept alive via `strings` for the duration of the + // call. `ReportEventW` does not retain pointers after return. + let _ = unsafe { + ReportEventW( + handle, + EVENTLOG_ERROR_TYPE, + 0, // wCategory + event_id, + None, // lpUserSid + 0, // dwDataSize (no binary payload) + Some(&string_ptrs), // lpStrings + None, // lpRawData + ) + }; + + // SAFETY: `handle` was produced by `RegisterEventSourceW` and is dropped here. + let _ = unsafe { DeregisterEventSource(handle) }; +} + +// --------------------------------------------------------------------------- +// verify_tunnel entry point +// --------------------------------------------------------------------------- + +/// Run one tunnel verification round. +/// +/// Reads `agent.json`, performs a QUIC handshake, sends one `RouteAdvertise` +/// followed by one `Heartbeat`, waits for the `HeartbeatAck`, and returns. +/// +/// The protocol does not (yet) carry an explicit `RouteAdvertiseAck`. We rely +/// on a paired `Heartbeat`/`HeartbeatAck` round-trip on the same control +/// stream — getting the ack back proves both `RouteAdvertise` was accepted +/// (the gateway-side handler updates the registry on receipt) and the control +/// stream is alive in both directions. +/// +/// On failure, this function returns the operator-facing [`ErrorTriple`]. +pub async fn verify_tunnel(conf_handle: &ConfHandle, timeout: Duration) -> Result<(), ErrorTriple> { + tokio::time::timeout(timeout, run_verification(conf_handle)) + .await + .unwrap_or_else(|_elapsed| { + Err(ErrorTriple::new( + ErrorKind::QuicHandshakeTimeout, + format!("Verification timed out after {}s", timeout.as_secs()), + )) + }) +} + +async fn run_verification(conf_handle: &ConfHandle) -> Result<(), ErrorTriple> { + // Ensure rustls crypto provider is installed (ring). + let _ = rustls::crypto::ring::default_provider().install_default(); + + let agent_conf = conf_handle.get_conf(); + let tunnel_conf = &agent_conf.tunnel; + + if !tunnel_conf.enabled { + return Err(ErrorTriple::new( + ErrorKind::UnexpectedError, + "Tunnel section in agent.json is disabled; nothing to verify".to_owned(), + )); + } + + let cert_path = &tunnel_conf.client_cert_path; + let key_path = &tunnel_conf.client_key_path; + let ca_path = &tunnel_conf.gateway_ca_cert_path; + + // ----- Build rustls client config ----- + + let client_crypto = build_client_crypto(cert_path.as_str(), key_path.as_str(), ca_path.as_str(), tunnel_conf) + .map_err(|e| { + ErrorTriple::new( + ErrorKind::UnexpectedError, + format!( + "Failed to build TLS client config: {e:#}; correlation_id={}; log=", + uuid::Uuid::new_v4() + ), + ) + })?; + + // ----- Resolve gateway endpoint ----- + + let endpoint_str = &tunnel_conf.gateway_endpoint; + let (gateway_hostname, _port_str) = split_host_port(endpoint_str).ok_or_else(|| { + ErrorTriple::new( + ErrorKind::UnexpectedError, + format!("gateway_endpoint {endpoint_str:?} is malformed (missing port separator)"), + ) + })?; + + let gateway_addr = match tokio::net::lookup_host(endpoint_str).await { + Ok(mut iter) => iter + .next() + .ok_or_else(|| dns_failed(gateway_hostname.as_str(), "no addresses returned"))?, + Err(error) => { + return Err(dns_failed(gateway_hostname.as_str(), &format!("{error}"))); + } + }; + + // ----- QUIC dial + handshake ----- + + let bind_addr: SocketAddr = if gateway_addr.is_ipv4() { + (Ipv4Addr::UNSPECIFIED, 0).into() + } else { + (Ipv6Addr::UNSPECIFIED, 0).into() + }; + + let mut endpoint = quinn::Endpoint::client(bind_addr).map_err(|error| { + ErrorTriple::new( + ErrorKind::UnexpectedError, + format!( + "Failed to create QUIC client endpoint: {error:#}; correlation_id={}; log=", + uuid::Uuid::new_v4() + ), + ) + })?; + + let mut transport = quinn::TransportConfig::default(); + transport + .max_idle_timeout(Some(Duration::from_secs(30).try_into().expect("30s -> idle timeout"))) + .keep_alive_interval(Some(Duration::from_secs(5))) + .max_concurrent_bidi_streams(8u32.into()); + let mut client_config = quinn::ClientConfig::new(Arc::new( + quinn::crypto::rustls::QuicClientConfig::try_from(client_crypto).map_err(|error| { + ErrorTriple::new( + ErrorKind::UnexpectedError, + format!( + "Failed to build QuicClientConfig: {error:#}; correlation_id={}; log=", + uuid::Uuid::new_v4() + ), + ) + })?, + )); + client_config.transport_config(Arc::new(transport)); + endpoint.set_default_client_config(client_config); + + let connecting = endpoint + .connect(gateway_addr, gateway_hostname.as_str()) + .map_err(|error| { + // `Endpoint::connect` returns synchronously for argument errors only. + ErrorTriple::new( + ErrorKind::UnexpectedError, + format!( + "QUIC connect call failed: {error:#}; correlation_id={}; log=", + uuid::Uuid::new_v4() + ), + ) + })?; + + let connection = match connecting.await { + Ok(conn) => conn, + Err(error) => return Err(classify_handshake_error(&error, gateway_hostname.as_str())), + }; + + // ----- Control-plane round-trip ----- + + let mut ctrl: ControlStream<_, _> = connection + .open_bi() + .await + .map_err(|error| { + ErrorTriple::new( + ErrorKind::RouteAdvertiseTimeout, + format!("QUIC connected but could not open control stream: {error:#}"), + ) + })? + .into(); + + // Send a minimal RouteAdvertise (no subnets/domains — verify-tunnel is a + // probe, not a real registration). The gateway updates its registry on + // receipt; agents normally re-send the real list on first reconnect after + // verify-tunnel exits. + let advertise = ControlMessage::route_advertise(0, Vec::::new(), Vec::new()); + ctrl.send(&advertise).await.map_err(|error| { + ErrorTriple::new( + ErrorKind::RouteAdvertiseTimeout, + format!("Failed to send RouteAdvertise on control stream: {error:#}"), + ) + })?; + + // Send a Heartbeat and wait for its HeartbeatAck — the only ack-bearing + // round-trip currently defined by the protocol. The ack proves both + // RouteAdvertise was accepted (gateway processed the prior message on the + // same stream before reading this one) and the control stream is alive in + // both directions. + let ts = current_time_millis(); + let heartbeat = ControlMessage::heartbeat(ts, 0); + ctrl.send(&heartbeat).await.map_err(|error| { + ErrorTriple::new( + ErrorKind::RouteAdvertiseTimeout, + format!("Failed to send Heartbeat on control stream: {error:#}"), + ) + })?; + + // Read messages from the gateway until we observe a HeartbeatAck for our + // ts (or any HeartbeatAck — single-shot probe), with a tight inner timeout + // so the outer 10s budget isn't consumed entirely by one stuck recv. + let inner_timeout = Duration::from_secs(5); + match tokio::time::timeout(inner_timeout, await_heartbeat_ack(&mut ctrl, ts)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + return Err(ErrorTriple::new( + ErrorKind::RouteAdvertiseTimeout, + format!("Control stream error while waiting for HeartbeatAck: {error:#}"), + )); + } + Err(_) => { + return Err(ErrorTriple::new( + ErrorKind::RouteAdvertiseTimeout, + format!("QUIC connected, no HeartbeatAck in {}s", inner_timeout.as_secs()), + )); + } + } + + // Gracefully close. + connection.close(0u32.into(), b"verify-tunnel done"); + endpoint.close(0u32.into(), b"verify-tunnel done"); + + Ok(()) +} + +async fn await_heartbeat_ack(ctrl: &mut ControlStream, expected_ts: u64) -> anyhow::Result<()> +where + S: tokio::io::AsyncWrite + Unpin, + R: tokio::io::AsyncRead + Unpin, +{ + loop { + let msg = ctrl.recv().await.context("read control message")?; + match msg { + ControlMessage::HeartbeatAck { timestamp_ms, .. } if timestamp_ms == expected_ts => return Ok(()), + // The Gateway may interleave other messages on the control stream; ignore them. + ControlMessage::HeartbeatAck { .. } + | ControlMessage::Heartbeat { .. } + | ControlMessage::RouteAdvertise { .. } + | ControlMessage::CertRenewalResponse { .. } + | ControlMessage::CertRenewalRequest { .. } => continue, + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Split a `host:port` / `[ipv6]:port` endpoint into the host string and the +/// port string. Returns `None` when no port separator is found. +fn split_host_port(endpoint: &str) -> Option<(String, String)> { + let trimmed = endpoint.trim(); + if let Some((host, port)) = trimmed.rsplit_once(']') { + // IPv6 form: `[host]:port`. + let host = host.strip_prefix('[')?; + let port = port.strip_prefix(':')?; + Some((host.to_owned(), port.to_owned())) + } else { + // DNS / IPv4 form: `host:port`. + let (host, port) = trimmed.rsplit_once(':')?; + Some((host.to_owned(), port.to_owned())) + } +} + +fn dns_failed(host: &str, raw: &str) -> ErrorTriple { + ErrorTriple::new( + ErrorKind::DnsResolutionFailed, + format!("Could not resolve '{host}' from this machine ({raw})"), + ) +} + +/// Map a Quinn connection error to the most useful operator-facing kind. +/// +/// Quinn surfaces a small set of `ConnectionError` variants. We split them +/// into: +/// - TLS verification failures (the cert SAN didn't match, or our pinned SPKI +/// verifier returned `General`), +/// - Handshake timeouts (no UDP path to the gateway, or path drops mid-flow), +/// - Anything else as `unexpected_error`. +fn classify_handshake_error(error: &quinn::ConnectionError, host: &str) -> ErrorTriple { + use quinn::ConnectionError as Ce; + match error { + Ce::TimedOut => ErrorTriple::new( + ErrorKind::UdpUnreachable, + format!("Resolved {host} -> gateway, but no QUIC initial response (UDP blocked or no listener)"), + ), + Ce::ConnectionClosed(_) | Ce::ApplicationClosed(_) | Ce::Reset | Ce::LocallyClosed => ErrorTriple::new( + ErrorKind::QuicHandshakeTimeout, + format!("QUIC connection closed before handshake completed: {error}"), + ), + Ce::TransportError(transport_error) => { + let detail = format!("{transport_error}"); + // The exact string from rustls for SPKI pin mismatch is + // "General(\"server SPKI hash does not match pinned value from enrollment\")". + if detail.contains("server SPKI hash does not match pinned value") { + ErrorTriple::new( + ErrorKind::TlsSpkiPinMismatch, + format!( + "Pinned SPKI does not match server-presented SPKI ({}); host={host}", + detail + ), + ) + } else if detail.contains("NotValidForName") + || detail.contains("CertNotValidForName") + || detail.contains("not valid for name") + { + ErrorTriple::new( + ErrorKind::TlsSanMismatch, + format!("Connecting as '{host}' but server cert SAN does not include it ({detail})"), + ) + } else { + ErrorTriple::new( + ErrorKind::QuicHandshakeTimeout, + format!("QUIC transport error during handshake: {detail}"), + ) + } + } + other => ErrorTriple::new( + ErrorKind::UnexpectedError, + format!( + "QUIC handshake failed: {other}; correlation_id={}; log=", + uuid::Uuid::new_v4() + ), + ), + } +} + +/// Build a rustls `ClientConfig` mirroring [`crate::tunnel`]'s setup: mTLS +/// client auth + chain validation + SPKI pinning (when one was recorded at +/// enrollment). +fn build_client_crypto( + cert_path: &str, + key_path: &str, + ca_path: &str, + tunnel_conf: &crate::config::TunnelConf, +) -> anyhow::Result { + let certs: Vec> = rustls_pemfile::certs(&mut std::io::BufReader::new( + std::fs::File::open(cert_path).context("open client cert file")?, + )) + .collect::, _>>() + .context("parse client certificates")?; + + let key = rustls_pemfile::private_key(&mut std::io::BufReader::new( + std::fs::File::open(key_path).context("open client key file")?, + )) + .context("parse private key file")? + .context("no private key found in file")?; + + let mut roots = rustls::RootCertStore::empty(); + let ca_certs: Vec> = rustls_pemfile::certs(&mut std::io::BufReader::new( + std::fs::File::open(ca_path).context("open CA cert file")?, + )) + .collect::, _>>() + .context("parse CA certificates")?; + for cert in ca_certs { + roots.add(cert)?; + } + + let verifier = rustls::client::WebPkiServerVerifier::builder(Arc::new(roots)) + .build() + .context("build server cert verifier")?; + + let effective_verifier: Arc = if let Some(ref expected_spki) = + tunnel_conf.server_spki_sha256 + { + Arc::new(SpkiPinnedVerifier { + inner: verifier, + expected_spki_sha256: expected_spki.clone(), + }) + } else { + verifier + }; + + let mut client_crypto = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(effective_verifier) + .with_client_auth_cert(certs, key) + .context("build rustls client config with client auth")?; + + client_crypto.alpn_protocols = vec![agent_tunnel_proto::ALPN_PROTOCOL.to_vec()]; + + Ok(client_crypto) +} + +/// Mirrors `tunnel::SpkiPinnedVerifier` — but kept local so `verify-tunnel` +/// can run independently of the long-running tunnel task module. +#[derive(Debug)] +struct SpkiPinnedVerifier { + inner: Arc, + expected_spki_sha256: String, +} + +impl rustls::client::danger::ServerCertVerifier for SpkiPinnedVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls_pki_types::CertificateDer<'_>, + intermediates: &[rustls_pki_types::CertificateDer<'_>], + server_name: &rustls_pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls_pki_types::UnixTime, + ) -> Result { + self.inner + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)?; + + let (_, cert) = x509_parser::parse_x509_certificate(end_entity.as_ref()) + .map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding))?; + + let spki_hash = hex::encode(sha2::Sha256::digest(cert.public_key().raw)); + if spki_hash != self.expected_spki_sha256 { + return Err(rustls::Error::General( + "server SPKI hash does not match pinned value from enrollment".into(), + )); + } + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls_pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls_pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_kinds_have_stable_wire_strings() { + assert_eq!(ErrorKind::DnsResolutionFailed.as_str(), "dns_resolution_failed"); + assert_eq!(ErrorKind::TlsSanMismatch.as_str(), "tls_san_mismatch"); + assert_eq!(ErrorKind::UnexpectedError.as_str(), "unexpected_error"); + } + + #[test] + fn error_triple_serializes_to_single_line_json_with_three_fields() { + let triple = ErrorTriple::new( + ErrorKind::DnsResolutionFailed, + "Could not resolve 'gateway.corp' from this machine", + ); + let json = serde_json::to_string(&triple).expect("serialize triple"); + // Field order is fixed. + assert!( + json.starts_with(r#"{"kind":"dns_resolution_failed""#), + "got: {json}" + ); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse JSON"); + assert_eq!(parsed["kind"], "dns_resolution_failed"); + assert!(parsed["detail"].as_str().unwrap().contains("gateway.corp")); + assert!(parsed["next_step"].as_str().unwrap().contains("DNS entry")); + // Single-line (no embedded newlines). + assert!(!json.contains('\n')); + } + + #[test] + fn error_triple_with_custom_next_step() { + let triple = ErrorTriple::with_next_step( + ErrorKind::EnrollmentHostNotAdvertised, + "Gateway advertises: [..]. JWT used host: evil.example.com", + "Custom help from gateway response", + ); + assert_eq!(triple.kind, "enrollment_host_not_advertised"); + assert_eq!(triple.next_step, "Custom help from gateway response"); + } + + #[test] + fn unexpected_error_carries_correlation_id_and_log_pointer() { + let triple = ErrorTriple::new( + ErrorKind::UnexpectedError, + "Unexpected failure during phase=dns; correlation_id=12345; log=C:/ProgramData/.../agent.log", + ); + assert!(triple.detail.contains("correlation_id=")); + assert!(triple.detail.contains("log=")); + } + + #[test] + fn split_host_port_dns() { + let (h, p) = split_host_port("gateway.example.com:4433").unwrap(); + assert_eq!(h, "gateway.example.com"); + assert_eq!(p, "4433"); + } + + #[test] + fn split_host_port_ipv4() { + let (h, p) = split_host_port("10.10.0.7:4433").unwrap(); + assert_eq!(h, "10.10.0.7"); + assert_eq!(p, "4433"); + } + + #[test] + fn split_host_port_ipv6() { + let (h, p) = split_host_port("[fd00::7]:4433").unwrap(); + assert_eq!(h, "fd00::7"); + assert_eq!(p, "4433"); + } + + #[test] + fn split_host_port_rejects_no_port() { + assert!(split_host_port("gateway.example.com").is_none()); + } +} From dcff01ce4ae9938708fafcaa09a8abca900e7d06 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 17:56:12 -0400 Subject: [PATCH 08/11] feat(installer): wire post-enroll tunnel verification and drop gateway-url override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After `agent.exe up` succeeds, the agent-tunnel installation now invokes `agent.exe verify-tunnel --timeout 10` and only reports success if a real QUIC handshake + RouteAdvertise/Heartbeat round-trip completes. The CA parses the structured JSON triple from agent stderr and surfaces `{kind, detail, next_step}` via `session.Message(InstallMessage.Error, ...)` so installer failure dialogs contain an actionable next step instead of "setup failed". The 10s timeout is hardcoded by design (no MSI property, no escape hatch); a few extra seconds of wall-clock budget guard against a misbehaving process. MSI rollbacks engage on any non-zero exit. Also drops the Gateway URL override field from the AgentTunnel dialog (textbox, label, hint, RowCount/RowStyles, property declaration, deferred-CA UsesProperties wiring, localization strings in en-us and fr-fr). With the identity refactor the enrollment JWT is the single source of truth for the agent-facing URL — overriding it server-side would defeat the host validation against AdvertisedNames on the gateway. --- .../Actions/AgentActions.cs | 28 ++++- .../Actions/CustomActions.cs | 113 +++++++++++++++++- .../Dialogs/AgentTunnelDialog.Designer.cs | 46 +------ .../Dialogs/AgentTunnelDialog.cs | 6 +- package/AgentWindowsManaged/Program.cs | 1 - .../Properties/AgentProperties.cs | 7 -- .../Resources/DevolutionsAgent_en-us.wxl | 2 - .../Resources/DevolutionsAgent_fr-fr.wxl | 2 - 8 files changed, 143 insertions(+), 62 deletions(-) diff --git a/package/AgentWindowsManaged/Actions/AgentActions.cs b/package/AgentWindowsManaged/Actions/AgentActions.cs index 5add55239..de9b3f146 100644 --- a/package/AgentWindowsManaged/Actions/AgentActions.cs +++ b/package/AgentWindowsManaged/Actions/AgentActions.cs @@ -294,7 +294,6 @@ internal static class AgentActions UsesProperties = string.Join(";", new[] { AgentProperties.AgentTunnelEnrollmentString, - AgentProperties.AgentTunnelGatewayUrl, AgentProperties.AgentTunnelAgentName, AgentProperties.AgentTunnelAdvertiseSubnets, AgentProperties.AgentTunnelAdvertiseDomains, @@ -302,6 +301,32 @@ internal static class AgentActions }.Select(p => $"{p}=[{p}]")), }; + /// + /// After `up` returns success, run `agent.exe verify-tunnel` with a hardcoded 10s timeout + /// to confirm the tunnel is actually reachable. + /// + /// + /// Per the identity refactor, the installer's "success" means "the tunnel is up", not just + /// "a cert was written to disk". The CA parses the agent's structured JSON triple from + /// stderr and surfaces it through `session.Message(InstallMessage.Error, ...)` so the + /// dialog box contains an actionable `next_step` for the operator. + /// + private static readonly ElevatedManagedAction verifyAgentTunnel = new( + new Id($"CA.{nameof(verifyAgentTunnel)}"), + CustomActions.VerifyAgentTunnel, + Return.check, + When.After, new Step(enrollAgentTunnel.Id), + Features.AGENT_TUNNEL_FEATURE.BeingInstall(), + Sequence.InstallExecuteSequence) + { + Execute = Execute.deferred, + Impersonate = false, + UsesProperties = string.Join(";", new[] + { + AgentProperties.InstallDir, + }.Select(p => $"{p}=[{p}]")), + }; + private static readonly ElevatedManagedAction registerExplorerCommand = new( CustomActions.RegisterExplorerCommand ) @@ -376,6 +401,7 @@ private static string UseProperties(IEnumerable properties) setFeaturesToConfigure, configureFeatures, enrollAgentTunnel, + verifyAgentTunnel, createProgramDataDirectory, setProgramDataDirectoryPermissions, createProgramDataPedmDirectories, diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index a10f47652..2ff073dee 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -325,7 +325,6 @@ public static ActionResult EnrollAgentTunnel(Session session) string enrollmentString = session.Property(AgentProperties.AgentTunnelEnrollmentString)?.Trim() ?? string.Empty; string subnetsArg = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets)?.Trim() ?? string.Empty; string domainsArg = session.Property(AgentProperties.AgentTunnelAdvertiseDomains)?.Trim() ?? string.Empty; - string gatewayUrlArg = session.Property(AgentProperties.AgentTunnelGatewayUrl)?.Trim() ?? string.Empty; string agentNameArg = session.Property(AgentProperties.AgentTunnelAgentName)?.Trim() ?? string.Empty; ActionResult Fail(string msg) @@ -361,7 +360,6 @@ ActionResult Fail(string msg) } 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}\""; @@ -472,6 +470,117 @@ private static void WriteAdvertiseDomainsToConfig(Session session, string domain } } + /// + /// After `EnrollAgentTunnel` succeeds, exercises one real QUIC handshake + + /// `RouteAdvertise`/`Heartbeat` round-trip via `agent.exe verify-tunnel` so the + /// installer only reports success when the tunnel is actually reachable. + /// + /// The agent emits a single-line JSON triple `{kind, detail, next_step}` on stderr + /// when verification fails. The CA parses that triple, logs it, and surfaces + /// `next_step` (the operator-facing help text) through `session.Message(InstallMessage.Error, ...)`. + /// If the agent exits with a non-zero status but stderr contains no parseable triple, + /// the CA falls back to a generic "unexpected_error" message — it never silently + /// swallows a failure. + /// + [CustomAction] + public static ActionResult VerifyAgentTunnel(Session session) + { + ActionResult Fail(string title, string detail, string nextStep) + { + string composed = string.IsNullOrEmpty(nextStep) + ? $"{title}\n\n{detail}" + : $"{title}\n\n{detail}\n\n{nextStep}"; + session.Log($"verify-tunnel failure: kind={title}; detail={detail}; next_step={nextStep}"); + using Record record = new(0) { FormatString = composed }; + session.Message(InstallMessage.Error, record); + return ActionResult.Failure; + } + + try + { + string installDir = session.Property(AgentProperties.InstallDir); + string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME); + + // The 10s budget is hardcoded by design: no MSI property, no escape hatch. + // If real deployments later need a longer budget for slow customer networks, + // expose a property then — not pre-emptively. + const int VerifyTimeoutSeconds = 10; + string arguments = $"verify-tunnel --timeout {VerifyTimeoutSeconds}"; + + session.Log($"Running verify-tunnel: {exePath} {arguments}"); + + ProcessStartInfo startInfo = new(exePath, arguments) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = ProgramDataDirectory, + }; + + using Process process = Process.Start(startInfo); + // Hard wall-clock cap a few seconds beyond the agent's own --timeout so a + // misbehaving process can't hang the installer. + if (!process.WaitForExit((VerifyTimeoutSeconds + 5) * 1000)) + { + try { process.Kill(); } catch { /* already gone */ } + return Fail( + "quic_handshake_timeout", + $"verify-tunnel exceeded {VerifyTimeoutSeconds + 5}s wall-clock budget without exiting.", + "Re-run the installer. If the failure repeats, network path likely drops UDP mid-flow; check Windows Firewall, NAT, and EDR network inspection."); + } + + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + + if (!string.IsNullOrEmpty(stdout)) session.Log($"verify-tunnel stdout: {stdout}"); + if (!string.IsNullOrEmpty(stderr)) session.Log($"verify-tunnel stderr: {stderr}"); + + if (process.ExitCode == 0) + { + session.Log("Agent tunnel verification succeeded"); + return ActionResult.Success; + } + + // Failure path: parse the last non-blank stderr line as the JSON triple. + string triple = (stderr ?? string.Empty) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Reverse() + .FirstOrDefault(l => l.TrimStart().StartsWith("{")); + + if (string.IsNullOrEmpty(triple)) + { + return Fail( + "unexpected_error", + $"verify-tunnel exited with code {process.ExitCode} but emitted no parseable JSON triple on stderr.", + "Collect the agent log and the installer log (.msi /l*v) and file a support issue."); + } + + try + { + JObject parsed = JObject.Parse(triple); + string kind = parsed.Value("kind") ?? "unexpected_error"; + string detail = parsed.Value("detail") ?? string.Empty; + string nextStep = parsed.Value("next_step") ?? string.Empty; + return Fail(kind, detail, nextStep); + } + catch (Exception e) + { + return Fail( + "unexpected_error", + $"verify-tunnel emitted unparseable stderr ({e.Message}). Raw: {triple}", + "Collect the agent log and the installer log (.msi /l*v) and file a support issue."); + } + } + catch (Exception e) + { + return Fail( + "unexpected_error", + $"Failed to invoke verify-tunnel: {e.Message}", + "Collect the agent log and the installer log (.msi /l*v) and file a support issue."); + } + } + [CustomAction] public static ActionResult ConfigureFeatures(Session session) { diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs index 19e7dc328..6cb99b636 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs @@ -44,9 +44,6 @@ 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(); @@ -93,18 +90,12 @@ private void InitializeComponent() 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 = 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.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()); @@ -234,38 +225,6 @@ 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) @@ -449,8 +408,5 @@ 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 577dc800a..123e59a76 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs @@ -21,11 +21,14 @@ public AgentTunnelDialog() public override bool ToProperties() { + // The Gateway URL override field was removed in the identity refactor: the JWT + // is now the single source of truth for the agent-facing URL. Overriding it + // server-side would defeat the whole point of validating that the agent reached + // the gateway through one of `AgentTunnel.AdvertisedNames`. 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(); return true; } @@ -38,7 +41,6 @@ public override void OnLoad(object sender, EventArgs e) 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); base.OnLoad(sender, e); } diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index c9e4ad964..e3a1547c1 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -352,7 +352,6 @@ static void Main() // 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 }); diff --git a/package/AgentWindowsManaged/Properties/AgentProperties.cs b/package/AgentWindowsManaged/Properties/AgentProperties.cs index b8facf3e4..f850ad79a 100644 --- a/package/AgentWindowsManaged/Properties/AgentProperties.cs +++ b/package/AgentWindowsManaged/Properties/AgentProperties.cs @@ -31,13 +31,6 @@ 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"; - /// /// 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. diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl index 5a97175d2..f0b639853 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl @@ -69,6 +69,4 @@ If it appears minimized then active it from the taskbar. 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 9fbd7d361..1cbf7732e 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl @@ -14,8 +14,6 @@ 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 Service à l’échelle du système pour étendre les fonctionnalités de Devolutions Gateway. Devolutions Inc. From 9f075cc4ef068d3d52a9437931869620f5451053 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 20:03:51 -0400 Subject: [PATCH 09/11] fix(installer): strip internal whitespace from pasted enrollment JWT --- .../Dialogs/AgentTunnelDialog.cs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs index 123e59a76..452e68e65 100644 --- a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs @@ -25,7 +25,15 @@ public override bool ToProperties() // is now the single source of truth for the agent-facing URL. Overriding it // server-side would defeat the whole point of validating that the agent reached // the gateway through one of `AgentTunnel.AdvertisedNames`. - Runtime.Session[AgentProperties.AgentTunnelEnrollmentString] = enrollmentString.Text.Trim(); + // + // Important: the enrollment string is persisted with ALL internal whitespace + // removed — not just edge `.Trim()`. The validation in `DoValidate` already + // works on the whitespace-stripped form (browsers and password managers + // routinely wrap long JWTs across lines on paste), so the value handed off + // to `agent.exe up --enrollment-string` must match what was validated. + // Otherwise a multiline pasted JWT passes validation here and then fails at + // enrollment with an opaque error from the agent's JWT parser. + Runtime.Session[AgentProperties.AgentTunnelEnrollmentString] = StripAllWhitespace(enrollmentString.Text); Runtime.Session[AgentProperties.AgentTunnelAgentName] = agentName.Text.Trim(); Runtime.Session[AgentProperties.AgentTunnelAdvertiseSubnets] = advertiseSubnets.Text.Trim(); Runtime.Session[AgentProperties.AgentTunnelAdvertiseDomains] = advertiseDomains.Text.Trim(); @@ -33,6 +41,15 @@ public override bool ToProperties() return true; } + /// + /// Strip every whitespace character from . Used to + /// canonicalize pasted JWTs (which often arrive wrapped onto multiple lines) + /// so that validation and the value persisted into the MSI session refer to + /// the exact same string. + /// + private static string StripAllWhitespace(string value) => + value is null ? string.Empty : Regex.Replace(value, @"\s+", ""); + public override void OnLoad(object sender, EventArgs e) { banner.Image = Runtime.Session.GetResourceBitmap("WixUI_Bmp_Banner"); @@ -58,7 +75,10 @@ public override bool DoValidate() // 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+", ""); + // + // Use the same whitespace-stripping helper as `ToProperties` so validation and + // persistence operate on byte-identical input. + string text = StripAllWhitespace(enrollmentString.Text); string[] parts = text.Split('.'); if (parts.Length != 3 || parts.Any(string.IsNullOrEmpty)) { From f84d63216f52789df346222300a1d099aa58793a Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 20:03:56 -0400 Subject: [PATCH 10/11] fix(installer): read child stdout/stderr concurrently to avoid pipe deadlock --- .../Actions/CustomActions.cs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index 2ff073dee..2265aaedd 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -376,13 +376,23 @@ ActionResult Fail(string msg) }; using Process process = Process.Start(startInfo); + + // Start draining stdout/stderr BEFORE WaitForExit. The default + // OS pipe buffer is small (4 KiB-ish on Windows), so if the + // child writes more than the buffer holds while we're still + // sitting on WaitForExit, the child blocks on a full pipe and + // we kill it for "timing out" — classic .NET deadlock. + // Running both reads concurrently (as tasks) means the buffer + // is drained as the child fills it. + System.Threading.Tasks.Task stdoutTask = process.StandardOutput.ReadToEndAsync(); + System.Threading.Tasks.Task stderrTask = process.StandardError.ReadToEndAsync(); if (!process.WaitForExit(60_000)) { try { process.Kill(); } catch { /* already gone */ } return Fail("Agent tunnel enrollment timed out after 60 seconds."); } - string stdout = process.StandardOutput.ReadToEnd(); - string stderr = process.StandardError.ReadToEnd(); + string stdout = stdoutTask.GetAwaiter().GetResult(); + string stderr = stderrTask.GetAwaiter().GetResult(); if (!string.IsNullOrEmpty(stdout)) session.Log($"enrollment stdout: {Redact(stdout)}"); if (!string.IsNullOrEmpty(stderr)) session.Log($"enrollment stderr: {Redact(stderr)}"); @@ -519,6 +529,15 @@ ActionResult Fail(string title, string detail, string nextStep) }; using Process process = Process.Start(startInfo); + + // Drain stdout/stderr concurrently BEFORE WaitForExit so a + // chatty child (verify-tunnel can emit a large diagnostic + // payload on the failure path) cannot fill its pipe buffer + // and deadlock waiting for us to read. See the matching + // pattern in `EnrollAgentTunnel`. + System.Threading.Tasks.Task stdoutTask = process.StandardOutput.ReadToEndAsync(); + System.Threading.Tasks.Task stderrTask = process.StandardError.ReadToEndAsync(); + // Hard wall-clock cap a few seconds beyond the agent's own --timeout so a // misbehaving process can't hang the installer. if (!process.WaitForExit((VerifyTimeoutSeconds + 5) * 1000)) @@ -530,8 +549,8 @@ ActionResult Fail(string title, string detail, string nextStep) "Re-run the installer. If the failure repeats, network path likely drops UDP mid-flow; check Windows Firewall, NAT, and EDR network inspection."); } - string stdout = process.StandardOutput.ReadToEnd(); - string stderr = process.StandardError.ReadToEnd(); + string stdout = stdoutTask.GetAwaiter().GetResult(); + string stderr = stderrTask.GetAwaiter().GetResult(); if (!string.IsNullOrEmpty(stdout)) session.Log($"verify-tunnel stdout: {stdout}"); if (!string.IsNullOrEmpty(stderr)) session.Log($"verify-tunnel stderr: {stderr}"); From 9322b1bfc7d149db1bc2ce0732fd4b7231a8dafb Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 22 May 2026 20:04:10 -0400 Subject: [PATCH 11/11] refactor(agent): reuse shared endpoint::split_endpoint helper in verify-tunnel --- devolutions-agent/src/verify_tunnel.rs | 47 +++----------------------- 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/devolutions-agent/src/verify_tunnel.rs b/devolutions-agent/src/verify_tunnel.rs index 95e5d2ac6..7555d5cd7 100644 --- a/devolutions-agent/src/verify_tunnel.rs +++ b/devolutions-agent/src/verify_tunnel.rs @@ -337,10 +337,10 @@ async fn run_verification(conf_handle: &ConfHandle) -> Result<(), ErrorTriple> { // ----- Resolve gateway endpoint ----- let endpoint_str = &tunnel_conf.gateway_endpoint; - let (gateway_hostname, _port_str) = split_host_port(endpoint_str).ok_or_else(|| { + let (gateway_hostname, _port) = crate::endpoint::split_endpoint(endpoint_str).map_err(|e| { ErrorTriple::new( ErrorKind::UnexpectedError, - format!("gateway_endpoint {endpoint_str:?} is malformed (missing port separator)"), + format!("gateway_endpoint {endpoint_str:?} is malformed: {e:#}"), ) })?; @@ -497,22 +497,6 @@ where // Helpers // --------------------------------------------------------------------------- -/// Split a `host:port` / `[ipv6]:port` endpoint into the host string and the -/// port string. Returns `None` when no port separator is found. -fn split_host_port(endpoint: &str) -> Option<(String, String)> { - let trimmed = endpoint.trim(); - if let Some((host, port)) = trimmed.rsplit_once(']') { - // IPv6 form: `[host]:port`. - let host = host.strip_prefix('[')?; - let port = port.strip_prefix(':')?; - Some((host.to_owned(), port.to_owned())) - } else { - // DNS / IPv4 form: `host:port`. - let (host, port) = trimmed.rsplit_once(':')?; - Some((host.to_owned(), port.to_owned())) - } -} - fn dns_failed(host: &str, raw: &str) -> ErrorTriple { ErrorTriple::new( ErrorKind::DnsResolutionFailed, @@ -740,29 +724,6 @@ mod tests { assert!(triple.detail.contains("log=")); } - #[test] - fn split_host_port_dns() { - let (h, p) = split_host_port("gateway.example.com:4433").unwrap(); - assert_eq!(h, "gateway.example.com"); - assert_eq!(p, "4433"); - } - - #[test] - fn split_host_port_ipv4() { - let (h, p) = split_host_port("10.10.0.7:4433").unwrap(); - assert_eq!(h, "10.10.0.7"); - assert_eq!(p, "4433"); - } - - #[test] - fn split_host_port_ipv6() { - let (h, p) = split_host_port("[fd00::7]:4433").unwrap(); - assert_eq!(h, "fd00::7"); - assert_eq!(p, "4433"); - } - - #[test] - fn split_host_port_rejects_no_port() { - assert!(split_host_port("gateway.example.com").is_none()); - } + // Endpoint splitting tests live in `crate::endpoint` — the shared helper + // is the single source of truth for `host:port` / `[ipv6]:port` parsing. }