Repository navigation
feat(agent): add virtual channel diagnostics #2193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Richard Markiewicz (thenextman)
merged 4 commits into
master
from
thenextman-investigate-rendezvous-hardened-hosts
Nov 12, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
aea8a40
feat(agent): add virtual channel diagnostics
thenextman 1eef0a8
fix(agent): accept any client refusal in channel diagnostics
thenextman e61b8a9
revert(agent): restore strict control check in channel diagnostics
thenextman 05eff71
fix(agent): detect RDP sessions by protocol type in channel diagnostics
thenextman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| 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; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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 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); | ||
|
|
||
| [DllImport("wtsapi32.dll", SetLastError = true)] | ||
| [return: MarshalAs(UnmanagedType.Bool)] | ||
| private static extern bool WTSVirtualChannelClose(IntPtr channel); | ||
|
|
||
| [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 = IsRdpSession(); | ||
|
|
||
| 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(); | ||
| } | ||
|
|
||
| // 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; 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( | ||
| 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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}"); | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.