Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions dotnet/DesktopAgent/Diagnostics/ChannelProbe.cs
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))
Comment thread
thenextman marked this conversation as resolved.
Resolved
{
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);
}
}
}
}
115 changes: 115 additions & 0 deletions dotnet/DesktopAgent/Diagnostics/ChannelProbeForm.cs
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}");
}
}
}
}
9 changes: 8 additions & 1 deletion dotnet/DesktopAgent/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Loading