Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3115c93

Browse files
committedMay 6, 2025·
feat: add support for RDP URIs
1 parent 78ff6da commit 3115c93

File tree

8 files changed

+605
-86
lines changed

8 files changed

+605
-86
lines changed
 

‎App/App.xaml.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public partial class App : Application
4141
#endif
4242

4343
private readonly ILogger<App> _logger;
44+
private readonly IUriHandler _uriHandler;
4445

4546
public App()
4647
{
@@ -72,6 +73,8 @@ public App()
7273
.Bind(builder.Configuration.GetSection(MutagenControllerConfigSection));
7374
services.AddSingleton<ISyncSessionController, MutagenController>();
7475
services.AddSingleton<IUserNotifier, UserNotifier>();
76+
services.AddSingleton<IRdpConnector, RdpConnector>();
77+
services.AddSingleton<IUriHandler, UriHandler>();
7578

7679
// SignInWindow views and view models
7780
services.AddTransient<SignInViewModel>();
@@ -98,6 +101,7 @@ public App()
98101

99102
_services = services.BuildServiceProvider();
100103
_logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!;
104+
_uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
101105

102106
InitializeComponent();
103107
}
@@ -190,7 +194,18 @@ public void OnActivated(object? sender, AppActivationArguments args)
190194
_logger.LogWarning("URI activation with null data");
191195
return;
192196
}
193-
HandleURIActivation(protoArgs.Uri);
197+
198+
try
199+
{
200+
// don't need to wait for it to complete.
201+
_ = _uriHandler.HandleUri(protoArgs.Uri);
202+
}
203+
catch (System.Exception e)
204+
{
205+
_logger.LogError(e, "unhandled exception while processing URI coder://{authority}{path}",
206+
protoArgs.Uri.Authority, protoArgs.Uri.AbsolutePath);
207+
}
208+
194209
break;
195210

196211
case ExtendedActivationKind.AppNotification:
@@ -204,12 +219,6 @@ public void OnActivated(object? sender, AppActivationArguments args)
204219
}
205220
}
206221

207-
public void HandleURIActivation(Uri uri)
208-
{
209-
// don't log the query string as that's where we include some sensitive information like passwords
210-
_logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath);
211-
}
212-
213222
public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args)
214223
{
215224
// right now, we don't do anything other than log

‎App/Services/CredentialManager.cs

Lines changed: 142 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Net.Sockets;
23
using System.Runtime.InteropServices;
34
using System.Text;
45
using System.Text.Json;
@@ -307,7 +308,7 @@ public WindowsCredentialBackend(string credentialsTargetName)
307308

308309
public Task<RawCredentials?> ReadCredentials(CancellationToken ct = default)
309310
{
310-
var raw = NativeApi.ReadCredentials(_credentialsTargetName);
311+
var raw = Wincred.ReadCredentials(_credentialsTargetName);
311312
if (raw == null) return Task.FromResult<RawCredentials?>(null);
312313

313314
RawCredentials? credentials;
@@ -326,115 +327,179 @@ public WindowsCredentialBackend(string credentialsTargetName)
326327
public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default)
327328
{
328329
var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials);
329-
NativeApi.WriteCredentials(_credentialsTargetName, raw);
330+
Wincred.WriteCredentials(_credentialsTargetName, raw);
330331
return Task.CompletedTask;
331332
}
332333

333334
public Task DeleteCredentials(CancellationToken ct = default)
334335
{
335-
NativeApi.DeleteCredentials(_credentialsTargetName);
336+
Wincred.DeleteCredentials(_credentialsTargetName);
336337
return Task.CompletedTask;
337338
}
338339

339-
private static class NativeApi
340+
}
341+
342+
/// <summary>
343+
/// Wincred provides relatively low level wrapped calls to the Wincred.h native API.
344+
/// </summary>
345+
internal static class Wincred
346+
{
347+
private const int CredentialTypeGeneric = 1;
348+
private const int CredentialTypeDomainPassword = 2;
349+
private const int PersistenceTypeLocalComputer = 2;
350+
private const int ErrorNotFound = 1168;
351+
private const int CredMaxCredentialBlobSize = 5 * 512;
352+
private const string PackageNTLM = "NTLM";
353+
354+
public static string? ReadCredentials(string targetName)
340355
{
341-
private const int CredentialTypeGeneric = 1;
342-
private const int PersistenceTypeLocalComputer = 2;
343-
private const int ErrorNotFound = 1168;
344-
private const int CredMaxCredentialBlobSize = 5 * 512;
356+
if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr))
357+
{
358+
var error = Marshal.GetLastWin32Error();
359+
if (error == ErrorNotFound) return null;
360+
throw new InvalidOperationException($"Failed to read credentials (Error {error})");
361+
}
345362

346-
public static string? ReadCredentials(string targetName)
363+
try
347364
{
348-
if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr))
349-
{
350-
var error = Marshal.GetLastWin32Error();
351-
if (error == ErrorNotFound) return null;
352-
throw new InvalidOperationException($"Failed to read credentials (Error {error})");
353-
}
365+
var cred = Marshal.PtrToStructure<CREDENTIALW>(credentialPtr);
366+
return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char));
367+
}
368+
finally
369+
{
370+
CredFree(credentialPtr);
371+
}
372+
}
354373

355-
try
356-
{
357-
var cred = Marshal.PtrToStructure<CREDENTIAL>(credentialPtr);
358-
return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char));
359-
}
360-
finally
374+
public static void WriteCredentials(string targetName, string secret)
375+
{
376+
var byteCount = Encoding.Unicode.GetByteCount(secret);
377+
if (byteCount > CredMaxCredentialBlobSize)
378+
throw new ArgumentOutOfRangeException(nameof(secret),
379+
$"The secret is greater than {CredMaxCredentialBlobSize} bytes");
380+
381+
var credentialBlob = Marshal.StringToHGlobalUni(secret);
382+
var cred = new CREDENTIALW
383+
{
384+
Type = CredentialTypeGeneric,
385+
TargetName = targetName,
386+
CredentialBlobSize = byteCount,
387+
CredentialBlob = credentialBlob,
388+
Persist = PersistenceTypeLocalComputer,
389+
};
390+
try
391+
{
392+
if (!CredWriteW(ref cred, 0))
361393
{
362-
CredFree(credentialPtr);
394+
var error = Marshal.GetLastWin32Error();
395+
throw new InvalidOperationException($"Failed to write credentials (Error {error})");
363396
}
364397
}
365-
366-
public static void WriteCredentials(string targetName, string secret)
398+
finally
367399
{
368-
var byteCount = Encoding.Unicode.GetByteCount(secret);
369-
if (byteCount > CredMaxCredentialBlobSize)
370-
throw new ArgumentOutOfRangeException(nameof(secret),
371-
$"The secret is greater than {CredMaxCredentialBlobSize} bytes");
400+
Marshal.FreeHGlobal(credentialBlob);
401+
}
402+
}
372403

373-
var credentialBlob = Marshal.StringToHGlobalUni(secret);
374-
var cred = new CREDENTIAL
375-
{
376-
Type = CredentialTypeGeneric,
377-
TargetName = targetName,
378-
CredentialBlobSize = byteCount,
379-
CredentialBlob = credentialBlob,
380-
Persist = PersistenceTypeLocalComputer,
381-
};
382-
try
383-
{
384-
if (!CredWriteW(ref cred, 0))
385-
{
386-
var error = Marshal.GetLastWin32Error();
387-
throw new InvalidOperationException($"Failed to write credentials (Error {error})");
388-
}
389-
}
390-
finally
391-
{
392-
Marshal.FreeHGlobal(credentialBlob);
393-
}
404+
public static void DeleteCredentials(string targetName)
405+
{
406+
if (!CredDeleteW(targetName, CredentialTypeGeneric, 0))
407+
{
408+
var error = Marshal.GetLastWin32Error();
409+
if (error == ErrorNotFound) return;
410+
throw new InvalidOperationException($"Failed to delete credentials (Error {error})");
394411
}
412+
}
413+
414+
public static void WriteDomainCredentials(string domainName, string serverName, string username, string password)
415+
{
416+
var targetName = $"{domainName}/{serverName}";
417+
var targetInfo = new CREDENTIAL_TARGET_INFORMATIONW
418+
{
419+
TargetName = targetName,
420+
DnsServerName = serverName,
421+
DnsDomainName = domainName,
422+
PackageName = PackageNTLM,
423+
};
424+
var byteCount = Encoding.Unicode.GetByteCount(password);
425+
if (byteCount > CredMaxCredentialBlobSize)
426+
throw new ArgumentOutOfRangeException(nameof(password),
427+
$"The secret is greater than {CredMaxCredentialBlobSize} bytes");
395428

396-
public static void DeleteCredentials(string targetName)
429+
var credentialBlob = Marshal.StringToHGlobalUni(password);
430+
var cred = new CREDENTIALW
397431
{
398-
if (!CredDeleteW(targetName, CredentialTypeGeneric, 0))
432+
Type = CredentialTypeDomainPassword,
433+
TargetName = targetName,
434+
CredentialBlobSize = byteCount,
435+
CredentialBlob = credentialBlob,
436+
Persist = PersistenceTypeLocalComputer,
437+
UserName = username,
438+
};
439+
try
440+
{
441+
if (!CredWriteDomainCredentialsW(ref targetInfo, ref cred, 0))
399442
{
400443
var error = Marshal.GetLastWin32Error();
401-
if (error == ErrorNotFound) return;
402-
throw new InvalidOperationException($"Failed to delete credentials (Error {error})");
444+
throw new InvalidOperationException($"Failed to write credentials (Error {error})");
403445
}
404446
}
447+
finally
448+
{
449+
Marshal.FreeHGlobal(credentialBlob);
450+
}
451+
}
405452

406-
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
407-
private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr);
453+
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
454+
private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr);
408455

409-
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
410-
private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags);
456+
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
457+
private static extern bool CredWriteW([In] ref CREDENTIALW userCredential, [In] uint flags);
411458

412-
[DllImport("Advapi32.dll", SetLastError = true)]
413-
private static extern void CredFree([In] IntPtr cred);
459+
[DllImport("Advapi32.dll", SetLastError = true)]
460+
private static extern void CredFree([In] IntPtr cred);
414461

415-
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
416-
private static extern bool CredDeleteW(string target, int type, int flags);
462+
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
463+
private static extern bool CredDeleteW(string target, int type, int flags);
417464

418-
[StructLayout(LayoutKind.Sequential)]
419-
private struct CREDENTIAL
420-
{
421-
public int Flags;
422-
public int Type;
465+
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
466+
private static extern bool CredWriteDomainCredentialsW([In] ref CREDENTIAL_TARGET_INFORMATIONW target, [In] ref CREDENTIALW userCredential, [In] uint flags);
423467

424-
[MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
468+
[StructLayout(LayoutKind.Sequential)]
469+
private struct CREDENTIALW
470+
{
471+
public int Flags;
472+
public int Type;
425473

426-
[MarshalAs(UnmanagedType.LPWStr)] public string Comment;
474+
[MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
427475

428-
public long LastWritten;
429-
public int CredentialBlobSize;
430-
public IntPtr CredentialBlob;
431-
public int Persist;
432-
public int AttributeCount;
433-
public IntPtr Attributes;
476+
[MarshalAs(UnmanagedType.LPWStr)] public string Comment;
434477

435-
[MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
478+
public long LastWritten;
479+
public int CredentialBlobSize;
480+
public IntPtr CredentialBlob;
481+
public int Persist;
482+
public int AttributeCount;
483+
public IntPtr Attributes;
436484

437-
[MarshalAs(UnmanagedType.LPWStr)] public string UserName;
438-
}
485+
[MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
486+
487+
[MarshalAs(UnmanagedType.LPWStr)] public string UserName;
488+
}
489+
490+
[StructLayout(LayoutKind.Sequential)]
491+
private struct CREDENTIAL_TARGET_INFORMATIONW
492+
{
493+
[MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
494+
[MarshalAs(UnmanagedType.LPWStr)] public string NetbiosServerName;
495+
[MarshalAs(UnmanagedType.LPWStr)] public string DnsServerName;
496+
[MarshalAs(UnmanagedType.LPWStr)] public string NetbiosDomainName;
497+
[MarshalAs(UnmanagedType.LPWStr)] public string DnsDomainName;
498+
[MarshalAs(UnmanagedType.LPWStr)] public string DnsTreeName;
499+
[MarshalAs(UnmanagedType.LPWStr)] public string PackageName;
500+
501+
public uint Flags;
502+
public uint CredTypeCount;
503+
public IntPtr CredTypes;
439504
}
440505
}

‎App/Services/RdpConnector.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Coder.Desktop.App.Services;
8+
9+
public struct RdpCredentials(string username, string password)
10+
{
11+
public readonly string Username = username;
12+
public readonly string Password = password;
13+
}
14+
15+
public interface IRdpConnector : IAsyncDisposable
16+
{
17+
public const int DefaultPort = 3389;
18+
19+
public Task WriteCredentials(string fqdn, RdpCredentials credentials, CancellationToken ct = default);
20+
21+
public Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default);
22+
}
23+
24+
public class RdpConnector(ILogger<RdpConnector> logger) : IRdpConnector
25+
{
26+
// Remote Desktop always uses TERMSRV as the domain; RDP is a part of Windows "Terminal Services".
27+
private const string RdpDomain = "TERMSRV";
28+
29+
public Task WriteCredentials(string fqdn, RdpCredentials credentials, CancellationToken ct = default)
30+
{
31+
// writing credentials is idempotent for the same domain and server name.
32+
Wincred.WriteDomainCredentials(RdpDomain, fqdn, credentials.Username, credentials.Password);
33+
logger.LogDebug("wrote domain credential for {serverName} with username {username}", fqdn,
34+
credentials.Username);
35+
return Task.CompletedTask;
36+
}
37+
38+
public Task Connect(string fqdn, int port = IRdpConnector.DefaultPort, CancellationToken ct = default)
39+
{
40+
// use mstsc to launch the RDP connection
41+
var mstscProc = new Process();
42+
mstscProc.StartInfo.FileName = "mstsc";
43+
var args = $"/v {fqdn}";
44+
if (port != IRdpConnector.DefaultPort)
45+
{
46+
args = $"/v {fqdn}:{port}";
47+
}
48+
49+
mstscProc.StartInfo.Arguments = args;
50+
mstscProc.StartInfo.CreateNoWindow = true;
51+
mstscProc.StartInfo.UseShellExecute = false;
52+
try
53+
{
54+
if (!mstscProc.Start())
55+
throw new InvalidOperationException("Failed to start mstsc, Start returned false");
56+
}
57+
catch (Exception e)
58+
{
59+
logger.LogWarning(e, "mstsc failed to start");
60+
61+
try
62+
{
63+
mstscProc.Kill();
64+
}
65+
catch
66+
{
67+
// ignored, the process likely doesn't exist
68+
}
69+
70+
mstscProc.Dispose();
71+
throw;
72+
}
73+
74+
return mstscProc.WaitForExitAsync(ct);
75+
}
76+
77+
public ValueTask DisposeAsync()
78+
{
79+
return ValueTask.CompletedTask;
80+
}
81+
}

‎App/Services/UriHandler.cs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
using System;
2+
using System.Collections.Specialized;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using System.Web;
7+
using Coder.Desktop.App.Models;
8+
using Coder.Desktop.Vpn.Proto;
9+
using Microsoft.Extensions.Logging;
10+
11+
12+
namespace Coder.Desktop.App.Services;
13+
14+
public interface IUriHandler : IAsyncDisposable
15+
{
16+
public Task HandleUri(Uri uri, CancellationToken ct = default);
17+
}
18+
19+
public class UriHandler(
20+
ILogger<UriHandler> logger,
21+
IRpcController rpcController,
22+
IUserNotifier userNotifier,
23+
IRdpConnector rdpConnector) : IUriHandler
24+
{
25+
private const string OpenWorkspacePrefix = "/v0/open/ws/";
26+
27+
internal class UriException(string title, string detail) : Exception
28+
{
29+
internal readonly string Title = title;
30+
internal readonly string Detail = detail;
31+
}
32+
33+
public async Task HandleUri(Uri uri, CancellationToken ct = default)
34+
{
35+
try
36+
{
37+
await HandleUriThrowingErrors(uri, ct);
38+
}
39+
catch (UriException e)
40+
{
41+
await userNotifier.ShowErrorNotification(e.Title, e.Detail, ct);
42+
}
43+
}
44+
45+
private async Task HandleUriThrowingErrors(Uri uri, CancellationToken ct = default)
46+
{
47+
if (uri.AbsolutePath.StartsWith(OpenWorkspacePrefix))
48+
{
49+
await HandleOpenWorkspaceApp(uri, ct);
50+
return;
51+
}
52+
53+
logger.LogWarning("unhandled URI path {path}", uri.AbsolutePath);
54+
throw new UriException("URI handling error",
55+
$"URI with path {uri.AbsolutePath} is unsupported or malformed");
56+
}
57+
58+
public async Task HandleOpenWorkspaceApp(Uri uri, CancellationToken ct = default)
59+
{
60+
const string errTitle = "Open Workspace Application Error";
61+
var subpath = uri.AbsolutePath[OpenWorkspacePrefix.Length..];
62+
var components = subpath.Split("/");
63+
if (components.Length != 4 || components[1] != "agent")
64+
{
65+
logger.LogWarning("unsupported open workspace app format in URI {path}", uri.AbsolutePath);
66+
throw new UriException(errTitle, $"Failed to open {uri.AbsolutePath} because the format is unsupported.");
67+
}
68+
69+
var workspaceName = components[0];
70+
var agentName = components[2];
71+
var appName = components[3];
72+
73+
var state = rpcController.GetState();
74+
if (state.VpnLifecycle != VpnLifecycle.Started)
75+
{
76+
logger.LogDebug("got URI to open workspace {workspace}, but Coder Connect is not started", workspaceName);
77+
throw new UriException(errTitle,
78+
$"Failed to open application on {workspaceName} because Coder Connect is not started.");
79+
}
80+
81+
Workspace workspace;
82+
try
83+
{
84+
workspace = state.Workspaces.Single(w => w.Name == workspaceName);
85+
}
86+
catch (InvalidOperationException) // Single() throws this when nothing matches.
87+
{
88+
logger.LogDebug("got URI to open workspace {workspace}, but the workspace doesn't exist", workspaceName);
89+
throw new UriException(errTitle,
90+
$"Failed to open application on workspace {workspaceName} because it doesn't exist");
91+
}
92+
93+
Agent agent;
94+
try
95+
{
96+
agent = state.Agents.Single(a => a.WorkspaceId == workspace.Id && a.Name == agentName);
97+
}
98+
catch (InvalidOperationException) // Single() throws this when nothing matches.
99+
{
100+
logger.LogDebug("got URI to open workspace/agent {workspaceName}/{agentName}, but the agent doesn't exist",
101+
workspaceName, agentName);
102+
throw new UriException(errTitle,
103+
$"Failed to open application on workspace {workspaceName}, agent {agentName} because it doesn't exist.");
104+
}
105+
106+
if (appName != "rdp")
107+
{
108+
logger.LogWarning("unsupported agent application type {app}", appName);
109+
throw new UriException(errTitle,
110+
$"Failed to open agent in URI {uri.AbsolutePath} because application {appName} is unsupported");
111+
}
112+
113+
await OpenRDP(agent.Fqdn.First(), uri.Query, ct);
114+
}
115+
116+
public async Task OpenRDP(string domainName, string queryString, CancellationToken ct = default)
117+
{
118+
const string errTitle = "Workspace Remote Desktop Error";
119+
NameValueCollection query;
120+
try
121+
{
122+
query = HttpUtility.ParseQueryString(queryString);
123+
}
124+
catch (Exception ex)
125+
{
126+
// unfortunately, we can't safely write they query string to logs because it might contain
127+
// sensitive info like a password. This is also why we don't log the exception directly
128+
var trace = new System.Diagnostics.StackTrace(ex, false);
129+
logger.LogWarning("failed to parse open RDP query string: {classMethod}",
130+
trace?.GetFrame(0)?.GetMethod()?.ReflectedType?.FullName);
131+
throw new UriException(errTitle,
132+
"Failed to open remote desktop on a workspace because the URI was malformed");
133+
}
134+
135+
var username = query.Get("username");
136+
var password = query.Get("password");
137+
if (!string.IsNullOrEmpty(username))
138+
{
139+
password ??= string.Empty;
140+
await rdpConnector.WriteCredentials(domainName, new RdpCredentials(username, password), ct);
141+
}
142+
143+
await rdpConnector.Connect(domainName, ct: ct);
144+
}
145+
146+
public ValueTask DisposeAsync()
147+
{
148+
return ValueTask.CompletedTask;
149+
}
150+
}

‎App/Services/UserNotifier.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Threading;
23
using System.Threading.Tasks;
34
using Microsoft.Windows.AppNotifications;
45
using Microsoft.Windows.AppNotifications.Builder;
@@ -7,7 +8,7 @@ namespace Coder.Desktop.App.Services;
78

89
public interface IUserNotifier : IAsyncDisposable
910
{
10-
public Task ShowErrorNotification(string title, string message);
11+
public Task ShowErrorNotification(string title, string message, CancellationToken ct = default);
1112
}
1213

1314
public class UserNotifier : IUserNotifier
@@ -19,7 +20,7 @@ public ValueTask DisposeAsync()
1920
return ValueTask.CompletedTask;
2021
}
2122

22-
public Task ShowErrorNotification(string title, string message)
23+
public Task ShowErrorNotification(string title, string message, CancellationToken ct = default)
2324
{
2425
var builder = new AppNotificationBuilder().AddText(title).AddText(message);
2526
_notificationManager.Show(builder.BuildNotification());
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Coder.Desktop.App.Services;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Hosting;
4+
using Serilog;
5+
6+
namespace Coder.Desktop.Tests.App.Services;
7+
8+
[TestFixture]
9+
public class RdpConnectorTest
10+
{
11+
[Test(Description = "Spawns RDP for real")]
12+
[Ignore("Comment out to run manually")]
13+
[CancelAfter(30_000)]
14+
public async Task ConnectToRdp()
15+
{
16+
var builder = Host.CreateApplicationBuilder();
17+
builder.Services.AddSerilog();
18+
builder.Services.AddSingleton<IRdpConnector, RdpConnector>();
19+
var services = builder.Services.BuildServiceProvider();
20+
21+
var rdpConnector = (RdpConnector)services.GetService<IRdpConnector>()!;
22+
var creds = new RdpCredentials("Administrator", "coderRDP!");
23+
var workspace = "myworkspace.coder";
24+
await rdpConnector.WriteCredentials(workspace, creds);
25+
await rdpConnector.Connect(workspace);
26+
}
27+
}

‎Tests.App/Services/UriHandlerTest.cs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
using Coder.Desktop.App.Models;
2+
using Coder.Desktop.App.Services;
3+
using Coder.Desktop.Vpn.Proto;
4+
using Google.Protobuf;
5+
using Microsoft.Extensions.Hosting;
6+
using Microsoft.Extensions.Logging;
7+
using Moq;
8+
using Serilog;
9+
10+
namespace Coder.Desktop.Tests.App.Services;
11+
12+
[TestFixture]
13+
public class UriHandlerTest
14+
{
15+
[SetUp]
16+
public void SetupMocksAndUriHandler()
17+
{
18+
Serilog.Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.NUnitOutput().CreateLogger();
19+
var builder = Host.CreateApplicationBuilder();
20+
builder.Services.AddSerilog();
21+
var logger = (ILogger<UriHandler>)builder.Build().Services.GetService(typeof(ILogger<UriHandler>))!;
22+
23+
_mUserNotifier = new Mock<IUserNotifier>(MockBehavior.Strict);
24+
_mRdpConnector = new Mock<IRdpConnector>(MockBehavior.Strict);
25+
_mRpcController = new Mock<IRpcController>(MockBehavior.Strict);
26+
27+
uriHandler = new UriHandler(logger, _mRpcController.Object, _mUserNotifier.Object, _mRdpConnector.Object);
28+
}
29+
30+
[TearDown]
31+
public async Task CleanupUriHandler()
32+
{
33+
await uriHandler.DisposeAsync();
34+
}
35+
36+
private Mock<IUserNotifier> _mUserNotifier;
37+
private Mock<IRdpConnector> _mRdpConnector;
38+
private Mock<IRpcController> _mRpcController;
39+
private UriHandler uriHandler; // Unit under test.
40+
41+
[SetUp]
42+
public void AgentAndWorkspaceFixtures()
43+
{
44+
agent11 = new Agent();
45+
agent11.Fqdn.Add("workspace1.coder");
46+
agent11.Id = ByteString.CopyFrom(0x1, 0x1);
47+
agent11.WorkspaceId = ByteString.CopyFrom(0x1, 0x0);
48+
agent11.Name = "agent11";
49+
50+
workspace1 = new Workspace
51+
{
52+
Id = ByteString.CopyFrom(0x1, 0x0),
53+
Name = "workspace1",
54+
};
55+
56+
modelWithWorkspace1 = new RpcModel
57+
{
58+
VpnLifecycle = VpnLifecycle.Started,
59+
Workspaces = [workspace1],
60+
Agents = [agent11],
61+
};
62+
}
63+
64+
private Agent agent11;
65+
private Workspace workspace1;
66+
private RpcModel modelWithWorkspace1;
67+
68+
[Test(Description = "Open RDP with username & password")]
69+
[CancelAfter(30_000)]
70+
public async Task Mainline(CancellationToken ct)
71+
{
72+
var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?username=testy&password=sesame");
73+
74+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
75+
var expectedCred = new RdpCredentials("testy", "sesame");
76+
_ = _mRdpConnector.Setup(m => m.WriteCredentials(agent11.Fqdn[0], expectedCred, ct))
77+
.Returns(Task.CompletedTask);
78+
_ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct))
79+
.Returns(Task.CompletedTask);
80+
await uriHandler.HandleUri(input, ct);
81+
}
82+
83+
[Test(Description = "Open RDP with no credentials")]
84+
[CancelAfter(30_000)]
85+
public async Task NoCredentials(CancellationToken ct)
86+
{
87+
var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp");
88+
89+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
90+
_ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct))
91+
.Returns(Task.CompletedTask);
92+
await uriHandler.HandleUri(input, ct);
93+
}
94+
95+
[Test(Description = "Unknown app slug")]
96+
[CancelAfter(30_000)]
97+
public async Task UnknownApp(CancellationToken ct)
98+
{
99+
var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/someapp");
100+
101+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
102+
_mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny<string>(), It.IsRegex("someapp"), ct))
103+
.Returns(Task.CompletedTask);
104+
await uriHandler.HandleUri(input, ct);
105+
}
106+
107+
[Test(Description = "Unknown agent name")]
108+
[CancelAfter(30_000)]
109+
public async Task UnknownAgent(CancellationToken ct)
110+
{
111+
var input = new Uri("coder:/v0/open/ws/workspace1/agent/wrongagent/rdp");
112+
113+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
114+
_mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny<string>(), It.IsRegex("wrongagent"), ct))
115+
.Returns(Task.CompletedTask);
116+
await uriHandler.HandleUri(input, ct);
117+
}
118+
119+
[Test(Description = "Unknown workspace name")]
120+
[CancelAfter(30_000)]
121+
public async Task UnknownWorkspace(CancellationToken ct)
122+
{
123+
var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp");
124+
125+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
126+
_mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny<string>(), It.IsRegex("wrongworkspace"), ct))
127+
.Returns(Task.CompletedTask);
128+
await uriHandler.HandleUri(input, ct);
129+
}
130+
131+
[Test(Description = "Malformed Query String")]
132+
[CancelAfter(30_000)]
133+
public async Task MalformedQuery(CancellationToken ct)
134+
{
135+
// there might be some query string that gets the parser to throw an exception, but I could not find one.
136+
var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?%&##");
137+
138+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
139+
// treated the same as if we just didn't include credentials
140+
_ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct))
141+
.Returns(Task.CompletedTask);
142+
await uriHandler.HandleUri(input, ct);
143+
}
144+
145+
[Test(Description = "VPN not started")]
146+
[CancelAfter(30_000)]
147+
public async Task VPNNotStarted(CancellationToken ct)
148+
{
149+
var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp");
150+
151+
_mRpcController.Setup(m => m.GetState()).Returns(new RpcModel
152+
{
153+
VpnLifecycle = VpnLifecycle.Starting,
154+
});
155+
// Coder Connect is the user facing name, so make sure the error mentions it.
156+
_mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny<string>(), It.IsRegex("Coder Connect"), ct))
157+
.Returns(Task.CompletedTask);
158+
await uriHandler.HandleUri(input, ct);
159+
}
160+
161+
[Test(Description = "Wrong number of components")]
162+
[CancelAfter(30_000)]
163+
public async Task UnknownNumComponents(CancellationToken ct)
164+
{
165+
var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent11/rdp");
166+
167+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
168+
_mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny<string>(), It.IsAny<string>(), ct))
169+
.Returns(Task.CompletedTask);
170+
await uriHandler.HandleUri(input, ct);
171+
}
172+
173+
[Test(Description = "Unknown prefix")]
174+
[CancelAfter(30_000)]
175+
public async Task UnknownPrefix(CancellationToken ct)
176+
{
177+
var input = new Uri("coder:/v300/open/ws/workspace1/agent/agent11/rdp");
178+
179+
_mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1);
180+
_mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny<string>(), It.IsAny<string>(), ct))
181+
.Returns(Task.CompletedTask);
182+
await uriHandler.HandleUri(input, ct);
183+
}
184+
}

‎Tests.App/Tests.App.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2727
</PackageReference>
2828
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
29+
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
30+
<PackageReference Include="Serilog.Sinks.NUnit" Version="1.0.3" />
2931
</ItemGroup>
3032

3133
<ItemGroup>

0 commit comments

Comments
 (0)
Please sign in to comment.