From aea8a40857df9479070227d3441512c9bd91e4ce Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Thu, 12 Nov 2026 10:58:08 -0500 Subject: [PATCH 1/4] feat(agent): add virtual channel diagnostics --- .../DesktopAgent/Diagnostics/ChannelProbe.cs | 203 ++++++++++++++++++ .../Diagnostics/ChannelProbeForm.cs | 115 ++++++++++ dotnet/DesktopAgent/Program.cs | 9 +- 3 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs create mode 100644 dotnet/DesktopAgent/Diagnostics/ChannelProbeForm.cs diff --git a/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs new file mode 100644 index 0000000000..7a6bb94cc3 --- /dev/null +++ b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs @@ -0,0 +1,203 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; + +namespace Devolutions.Agent.Desktop.Diagnostics +{ + internal enum ProbeStatus + { + Pass, + Fail, + Skipped, + } + + internal sealed class ProbeResult + { + public ProbeResult(string channel, string expected, string actual, ProbeStatus status) + { + this.Channel = channel; + this.Expected = expected; + this.Actual = actual; + this.Status = status; + } + + public string Channel { get; } + + public string Expected { get; } + + public string Actual { get; } + + public ProbeStatus Status { get; } + } + + /// + /// Opens dynamic virtual channels from the current session with the same Windows call the session host uses, + /// and compares each outcome with the one a healthy remote session produces. + /// + internal static class ChannelProbe + { + private const string NowChannel = "Devolutions::Now::Agent"; + + // Registered by the Microsoft RDP client itself, so it opens whenever dynamic channels work. + private const string BuiltInChannel = "ECHO"; + + // No client registers this name, so the client refuses it. + private const string UnregisteredChannel = "Devolutions::Probe::Unregistered"; + + private const string SessionHostProcess = "DevolutionsSession"; + + private const int ErrorGenFailure = 31; + + private const uint WtsCurrentSession = 0xFFFFFFFF; + + private const uint WtsChannelOptionDynamic = 1; + + private const int SmRemoteSession = 0x1000; + + [DllImport("wtsapi32.dll", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern IntPtr WTSVirtualChannelOpenEx(uint sessionId, string virtualName, uint flags); + + [DllImport("wtsapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool WTSVirtualChannelClose(IntPtr channel); + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int index); + + internal static string Run() + { + int sessionId = Process.GetCurrentProcess().SessionId; + bool remote = GetSystemMetrics(SmRemoteSession) != 0; + + StringBuilder report = new StringBuilder(); + report.AppendLine("Virtual channel diagnostics"); + report.AppendLine($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss zzz}"); + report.AppendLine($"Session: {sessionId} ({(remote ? "RDP" : "console")})"); + report.AppendLine($"User: {WindowsIdentity.GetCurrent().Name}"); + report.AppendLine($"Elevated: {IsElevated()}"); + report.AppendLine($"OS: {Environment.OSVersion.Version}"); + report.AppendLine(); + + if (!remote) + { + report.AppendLine("Not run: virtual channels are only available in a remote desktop session."); + + return report.ToString(); + } + + ProbeResult[] results = + { + Probe(BuiltInChannel, expectedError: 0), + ProbeNow(sessionId), + Probe(UnregisteredChannel, expectedError: ErrorGenFailure), + }; + + foreach (ProbeResult result in results) + { + report.AppendLine(result.Channel); + report.AppendLine($" Expected: {result.Expected}"); + report.AppendLine($" Result: {result.Actual}"); + report.AppendLine($" Status: {result.Status.ToString().ToUpperInvariant()}"); + report.AppendLine(); + } + + int passed = results.Count(r => r.Status == ProbeStatus.Pass); + int run = results.Count(r => r.Status != ProbeStatus.Skipped); + report.AppendLine($"{passed} of {run} checks passed."); + + return report.ToString(); + } + + private static ProbeResult ProbeNow(int sessionId) + { + // UNTESTED: opening the Now channel while the session host is connected offers RDM a second channel, which + // replaces the live one; this check is the only guard, so when changing it, make sure the channel is never + // opened while a session host runs in this session. + if (IsSessionHostRunning(sessionId)) + { + return new ProbeResult( + NowChannel, + DescribeExpected(0), + "not opened: Devolutions Session is running in this session and holds the channel", + ProbeStatus.Skipped); + } + + return Probe(NowChannel, expectedError: 0); + } + + private static ProbeResult Probe(string channel, int expectedError) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + IntPtr handle = WTSVirtualChannelOpenEx(WtsCurrentSession, channel, WtsChannelOptionDynamic); + int error = handle == IntPtr.Zero ? Marshal.GetLastWin32Error() : 0; + stopwatch.Stop(); + + if (handle != IntPtr.Zero) + { + WTSVirtualChannelClose(handle); + } + + string actual = error == 0 + ? $"opened ({stopwatch.ElapsedMilliseconds} ms)" + : $"{DescribeError(error)} ({stopwatch.ElapsedMilliseconds} ms)"; + + return new ProbeResult( + channel, + DescribeExpected(expectedError), + actual, + error == expectedError ? ProbeStatus.Pass : ProbeStatus.Fail); + } + + private static string DescribeExpected(int expectedError) + { + return expectedError == 0 ? "opened" : DescribeError(expectedError); + } + + private static string DescribeError(int error) + { + return $"error {error} ({new Win32Exception(error).Message})"; + } + + private static bool IsSessionHostRunning(int sessionId) + { + Process[] processes = Process.GetProcessesByName(SessionHostProcess); + + try + { + return processes.Any(process => IsInSession(process, sessionId)); + } + finally + { + foreach (Process process in processes) + { + process.Dispose(); + } + } + } + + private static bool IsInSession(Process process, int sessionId) + { + try + { + return process.SessionId == sessionId; + } + catch (InvalidOperationException) + { + // The process exited between the lookup and the query. + return false; + } + } + + private static bool IsElevated() + { + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) + { + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + } + } +} diff --git a/dotnet/DesktopAgent/Diagnostics/ChannelProbeForm.cs b/dotnet/DesktopAgent/Diagnostics/ChannelProbeForm.cs new file mode 100644 index 0000000000..b336bc189b --- /dev/null +++ b/dotnet/DesktopAgent/Diagnostics/ChannelProbeForm.cs @@ -0,0 +1,115 @@ +using Devolutions.Agent.Desktop.Properties; +using System; +using System.Drawing; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace Devolutions.Agent.Desktop.Diagnostics +{ + internal sealed class ChannelProbeForm : Form + { + private readonly TextBox output; + + private readonly Button btnRun; + + private readonly Button btnCopy; + + public ChannelProbeForm() + { + Text = "Virtual channel diagnostics"; + StartPosition = FormStartPosition.CenterScreen; + MaximizeBox = MinimizeBox = false; + ShowInTaskbar = false; + Icon = ImageResources.AppIcon; + AutoScaleMode = AutoScaleMode.Dpi; + ClientSize = new Size(640, 420); + Padding = new Padding(12); + + this.output = new TextBox + { + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + Font = new Font("Consolas", 9f), + Dock = DockStyle.Fill, + }; + + this.btnRun = new Button { Text = "Run again", AutoSize = true }; + this.btnRun.Click += async (_, _) => await this.RunProbes(); + + this.btnCopy = new Button { Text = "Copy", AutoSize = true }; + this.btnCopy.Click += (_, _) => this.CopyReport(); + + Button btnClose = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.Cancel }; + + FlowLayoutPanel buttons = new FlowLayoutPanel + { + FlowDirection = FlowDirection.RightToLeft, + Dock = DockStyle.Fill, + AutoSize = true, + }; + + buttons.Controls.Add(btnClose); + buttons.Controls.Add(this.btnCopy); + buttons.Controls.Add(this.btnRun); + + TableLayoutPanel layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + }; + + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(this.output, 0, 0); + layout.Controls.Add(buttons, 0, 1); + + Controls.Add(layout); + CancelButton = btnClose; + + Shown += async (_, _) => await this.RunProbes(); + } + + private async Task RunProbes() + { + this.btnRun.Enabled = false; + this.btnCopy.Enabled = false; + this.output.Text = "Running..."; + + string report; + + try + { + report = await Task.Run(() => ChannelProbe.Run()); + Program.Log.Info(report); + } + catch (Exception e) + { + Program.Log.Error(e.ToString()); + report = $"Virtual channel diagnostics failed: {e.Message}"; + } + + if (this.IsDisposed) + { + return; + } + + this.output.Text = report; + this.btnRun.Enabled = true; + this.btnCopy.Enabled = true; + } + + private void CopyReport() + { + try + { + Clipboard.SetText(this.output.Text); + } + catch (Exception e) + { + Program.Log.Warn($"Failed to copy the report: {e.Message}"); + } + } + } +} diff --git a/dotnet/DesktopAgent/Program.cs b/dotnet/DesktopAgent/Program.cs index 882347771c..c7c0425037 100644 --- a/dotnet/DesktopAgent/Program.cs +++ b/dotnet/DesktopAgent/Program.cs @@ -1,4 +1,5 @@ -using Devolutions.Agent.Desktop.Properties; +using Devolutions.Agent.Desktop.Diagnostics; +using Devolutions.Agent.Desktop.Properties; using Devolutions.Agent.Desktop.Service; using Devolutions.Pedm.Client.Model; using Microsoft.Toolkit.Uwp.Notifications; @@ -181,6 +182,12 @@ private void ContextMenuOnPopup(object sender, EventArgs e) this.contextMenu.MenuItems.Add(new MenuItem("Updater") { Enabled = false, Checked = config?.Updater?.Enabled ?? false }); this.contextMenu.MenuItems.Add(new MenuItem("Session") { Enabled = false, Checked = config?.Session?.Enabled ?? false }); this.contextMenu.MenuItems.Add(new MenuItem("PEDM") { Enabled = false, Checked = config?.Pedm?.Enabled ?? false }); + this.contextMenu.MenuItems.Add(new MenuItem("-")); + this.contextMenu.MenuItems.Add(new MenuItem("Test virtual channels...", (_, _) => + { + using ChannelProbeForm probeWindow = new(); + probeWindow.ShowDialog(); + })); } if (config?.Pedm?.Enabled ?? false) From 1eef0a8ca9914100d745c6b36c83613d1878c924 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Thu, 12 Nov 2026 11:37:57 -0500 Subject: [PATCH 2/4] fix(agent): accept any client refusal in channel diagnostics --- .../DesktopAgent/Diagnostics/ChannelProbe.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs index 7a6bb94cc3..1fa3f3950f 100644 --- a/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs +++ b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs @@ -45,7 +45,8 @@ internal static class ChannelProbe // Registered by the Microsoft RDP client itself, so it opens whenever dynamic channels work. private const string BuiltInChannel = "ECHO"; - // No client registers this name, so the client refuses it. + // No client registers this name, so the client refuses it. The Microsoft client reports the refusal as error 31; + // other clients report their own status. private const string UnregisteredChannel = "Devolutions::Probe::Unregistered"; private const string SessionHostProcess = "DevolutionsSession"; @@ -93,7 +94,7 @@ internal static string Run() { Probe(BuiltInChannel, expectedError: 0), ProbeNow(sessionId), - Probe(UnregisteredChannel, expectedError: ErrorGenFailure), + Probe(UnregisteredChannel, expectedError: ErrorGenFailure, anyFailure: true), }; foreach (ProbeResult result in results) @@ -129,7 +130,7 @@ private static ProbeResult ProbeNow(int sessionId) return Probe(NowChannel, expectedError: 0); } - private static ProbeResult Probe(string channel, int expectedError) + private static ProbeResult Probe(string channel, int expectedError, bool anyFailure = false) { Stopwatch stopwatch = Stopwatch.StartNew(); IntPtr handle = WTSVirtualChannelOpenEx(WtsCurrentSession, channel, WtsChannelOptionDynamic); @@ -145,11 +146,13 @@ private static ProbeResult Probe(string channel, int expectedError) ? $"opened ({stopwatch.ElapsedMilliseconds} ms)" : $"{DescribeError(error)} ({stopwatch.ElapsedMilliseconds} ms)"; - return new ProbeResult( - channel, - DescribeExpected(expectedError), - actual, - error == expectedError ? ProbeStatus.Pass : ProbeStatus.Fail); + string expected = anyFailure + ? $"refused ({DescribeError(expectedError)} with the Microsoft client)" + : DescribeExpected(expectedError); + + bool pass = anyFailure ? error != 0 : error == expectedError; + + return new ProbeResult(channel, expected, actual, pass ? ProbeStatus.Pass : ProbeStatus.Fail); } private static string DescribeExpected(int expectedError) From e61b8a93acc1ec3d566cee68b82860b12cabd8b6 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Thu, 12 Nov 2026 11:39:08 -0500 Subject: [PATCH 3/4] revert(agent): restore strict control check in channel diagnostics --- .../DesktopAgent/Diagnostics/ChannelProbe.cs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs index 1fa3f3950f..7a6bb94cc3 100644 --- a/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs +++ b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs @@ -45,8 +45,7 @@ internal static class ChannelProbe // Registered by the Microsoft RDP client itself, so it opens whenever dynamic channels work. private const string BuiltInChannel = "ECHO"; - // No client registers this name, so the client refuses it. The Microsoft client reports the refusal as error 31; - // other clients report their own status. + // No client registers this name, so the client refuses it. private const string UnregisteredChannel = "Devolutions::Probe::Unregistered"; private const string SessionHostProcess = "DevolutionsSession"; @@ -94,7 +93,7 @@ internal static string Run() { Probe(BuiltInChannel, expectedError: 0), ProbeNow(sessionId), - Probe(UnregisteredChannel, expectedError: ErrorGenFailure, anyFailure: true), + Probe(UnregisteredChannel, expectedError: ErrorGenFailure), }; foreach (ProbeResult result in results) @@ -130,7 +129,7 @@ private static ProbeResult ProbeNow(int sessionId) return Probe(NowChannel, expectedError: 0); } - private static ProbeResult Probe(string channel, int expectedError, bool anyFailure = false) + private static ProbeResult Probe(string channel, int expectedError) { Stopwatch stopwatch = Stopwatch.StartNew(); IntPtr handle = WTSVirtualChannelOpenEx(WtsCurrentSession, channel, WtsChannelOptionDynamic); @@ -146,13 +145,11 @@ private static ProbeResult Probe(string channel, int expectedError, bool anyFail ? $"opened ({stopwatch.ElapsedMilliseconds} ms)" : $"{DescribeError(error)} ({stopwatch.ElapsedMilliseconds} ms)"; - string expected = anyFailure - ? $"refused ({DescribeError(expectedError)} with the Microsoft client)" - : DescribeExpected(expectedError); - - bool pass = anyFailure ? error != 0 : error == expectedError; - - return new ProbeResult(channel, expected, actual, pass ? ProbeStatus.Pass : ProbeStatus.Fail); + return new ProbeResult( + channel, + DescribeExpected(expectedError), + actual, + error == expectedError ? ProbeStatus.Pass : ProbeStatus.Fail); } private static string DescribeExpected(int expectedError) From 05eff7169545e5f4729c29289dcdf125b89ca489 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Thu, 12 Nov 2026 11:51:13 -0500 Subject: [PATCH 4/4] fix(agent): detect RDP sessions by protocol type in channel diagnostics --- .../DesktopAgent/Diagnostics/ChannelProbe.cs | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs index 7a6bb94cc3..5e0f47cc81 100644 --- a/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs +++ b/dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs @@ -56,7 +56,11 @@ internal static class ChannelProbe private const uint WtsChannelOptionDynamic = 1; - private const int SmRemoteSession = 0x1000; + private const int WtsClientProtocolType = 16; + + private const ushort ProtocolTypeRdp = 2; + + private static readonly IntPtr WtsCurrentServerHandle = IntPtr.Zero; [DllImport("wtsapi32.dll", SetLastError = true, CharSet = CharSet.Ansi)] private static extern IntPtr WTSVirtualChannelOpenEx(uint sessionId, string virtualName, uint flags); @@ -65,13 +69,22 @@ internal static class ChannelProbe [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WTSVirtualChannelClose(IntPtr channel); - [DllImport("user32.dll")] - private static extern int GetSystemMetrics(int index); + [DllImport("wtsapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool WTSQuerySessionInformationW( + IntPtr server, + uint sessionId, + int infoClass, + out IntPtr buffer, + out uint bytesReturned); + + [DllImport("wtsapi32.dll")] + private static extern void WTSFreeMemory(IntPtr memory); internal static string Run() { int sessionId = Process.GetCurrentProcess().SessionId; - bool remote = GetSystemMetrics(SmRemoteSession) != 0; + bool remote = IsRdpSession(); StringBuilder report = new StringBuilder(); report.AppendLine("Virtual channel diagnostics"); @@ -112,11 +125,32 @@ internal static string Run() return report.ToString(); } + // The protocol type, unlike SM_REMOTESESSION, also identifies RDP sessions that use RemoteFX vGPU. + private static bool IsRdpSession() + { + if (!WTSQuerySessionInformationW(WtsCurrentServerHandle, WtsCurrentSession, WtsClientProtocolType, out IntPtr buffer, out _)) + { + return false; + } + + try + { + return (ushort)Marshal.ReadInt16(buffer) == ProtocolTypeRdp; + } + finally + { + WTSFreeMemory(buffer); + } + } + private static ProbeResult ProbeNow(int sessionId) { // UNTESTED: opening the Now channel while the session host is connected offers RDM a second channel, which - // replaces the live one; this check is the only guard, so when changing it, make sure the channel is never - // opened while a session host runs in this session. + // replaces the live one; the process check is the only guard, so when changing it, make sure the channel is + // never opened while a session host runs in this session. + // The guard does not cover a host the Agent launches between the check and the open. That is accepted for + // this manual diagnostic, whose purpose is to run while no host is running; the result is a replaced + // channel. if (IsSessionHostRunning(sessionId)) { return new ProbeResult(