diff --git a/Cargo.lock b/Cargo.lock index ea165ee61..980260bba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,6 +1527,7 @@ dependencies = [ name = "ffi" version = "0.0.0" dependencies = [ + "anyhow", "diplomat", "diplomat-runtime", "embed-resource", @@ -1534,6 +1535,7 @@ dependencies = [ "ironrdp-cliprdr-native", "ironrdp-core", "ironrdp-dvc-pipe-proxy", + "ironrdp-rdcleanpath", "sspi", "thiserror 2.0.17", "tracing", diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 30d98d3db..22d5871ea 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -18,10 +18,12 @@ ironrdp = { path = "../crates/ironrdp", features = ["session", "connector", "dvc ironrdp-cliprdr-native.path = "../crates/ironrdp-cliprdr-native" ironrdp-dvc-pipe-proxy.path = "../crates/ironrdp-dvc-pipe-proxy" ironrdp-core = { path = "../crates/ironrdp-core", features = ["alloc"] } +ironrdp-rdcleanpath.path = "../crates/ironrdp-rdcleanpath" sspi = { version = "0.16", features = ["network_client"] } thiserror = "2" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1.0" [target.'cfg(windows)'.build-dependencies] embed-resource = "3.0" diff --git a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj index e29164c4e..802b8ad76 100644 --- a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj +++ b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj @@ -10,18 +10,18 @@ - - - - + + + + - + - + diff --git a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs index 341485271..4ab8ef3f3 100644 --- a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs +++ b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs @@ -7,6 +7,7 @@ using System; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Net.Security; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -22,7 +23,7 @@ public partial class MainWindow : Window readonly InputDatabase? _inputDatabase = InputDatabase.New(); ActiveStage? _activeStage; DecodedImage? _decodedImage; - Framed? _framed; + Framed? _framed; WinCliprdr? _cliprdr; private readonly RendererModel _renderModel; private Image? _imageControl; @@ -77,18 +78,49 @@ private void OnOpened(object? sender, EventArgs e) var username = Environment.GetEnvironmentVariable("IRONRDP_USERNAME"); var password = Environment.GetEnvironmentVariable("IRONRDP_PASSWORD"); - var domain = Environment.GetEnvironmentVariable("IRONRDP_DOMAIN"); + var domain = Environment.GetEnvironmentVariable("IRONRDP_DOMAIN"); // Optional var server = Environment.GetEnvironmentVariable("IRONRDP_SERVER"); + var portEnv = Environment.GetEnvironmentVariable("IRONRDP_PORT"); // Optional - if (username == null || password == null || domain == null || server == null) + // Gateway configuration (optional) + var gatewayUrl = Environment.GetEnvironmentVariable("IRONRDP_GATEWAY_URL"); + var gatewayToken = Environment.GetEnvironmentVariable("IRONRDP_GATEWAY_TOKEN"); + var tokengenUrl = Environment.GetEnvironmentVariable("IRONRDP_TOKENGEN_URL"); + + if (username == null || password == null || server == null) { var errorMessage = - "Please set the IRONRDP_USERNAME, IRONRDP_PASSWORD, IRONRDP_DOMAIN, and RONRDP_SERVER environment variables"; + "Please set the IRONRDP_USERNAME, IRONRDP_PASSWORD, and IRONRDP_SERVER environment variables"; Trace.TraceError(errorMessage); Close(); throw new InvalidProgramException(errorMessage); } + // Validate server is only domain or IP (no port allowed) + // i.e. "example.com" or "10.10.0.3" the port should go to the dedicated env var IRONRDP_PORT + if (server.Contains(':')) + { + var errorMessage = $"IRONRDP_SERVER must be a domain or IP address only, not '{server}'. Use IRONRDP_PORT for the port."; + Trace.TraceError(errorMessage); + Close(); + throw new InvalidProgramException(errorMessage); + } + + // Parse port from environment variable or use default + int port = 3389; + if (!string.IsNullOrEmpty(portEnv)) + { + if (!int.TryParse(portEnv, out port) || port <= 0 || port > 65535) + { + var errorMessage = $"IRONRDP_PORT must be a valid port number (1-65535), got '{portEnv}'"; + Trace.TraceError(errorMessage); + Close(); + throw new InvalidProgramException(errorMessage); + } + } + + Trace.TraceInformation($"Target server: {server}:{port}"); + var config = BuildConfig(username, password, domain, _renderModel.Width, _renderModel.Height); CliprdrBackendFactory? factory = null; @@ -106,15 +138,81 @@ private void OnOpened(object? sender, EventArgs e) BeforeConnectSetup(); Task.Run(async () => { - var (res, framed) = await Connection.Connect(config, server, factory); - this._decodedImage = DecodedImage.New(PixelFormat.RgbA32, res.GetDesktopSize().GetWidth(), - res.GetDesktopSize().GetHeight()); - this._activeStage = ActiveStage.New(res); - this._framed = framed; - ReadPduAndProcessActiveStage(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + try { - HandleClipboardEvents(); + ConnectionResult res; + + // Determine connection mode: Gateway or Direct + if (!string.IsNullOrEmpty(gatewayUrl)) + { + Trace.TraceInformation("=== GATEWAY MODE ==="); + Trace.TraceInformation($"Gateway URL: {gatewayUrl}"); + Trace.TraceInformation($"Destination: {server}:{port}"); + + var tokenGen = new TokenGenerator(tokengenUrl ?? "http://localhost:8080"); + + // Generate RDP token if not provided + if (string.IsNullOrEmpty(gatewayToken)) + { + Trace.TraceInformation("No RDP token provided, generating token..."); + + try + { + gatewayToken = await tokenGen.GenerateRdpTlsToken( + dstHost: server!, + proxyUser: string.IsNullOrEmpty(domain) ? username : $"{username}@{domain}", + proxyPassword: password!, + destUser: username!, + destPassword: password! + ); + Trace.TraceInformation($"RDP token generated successfully (length: {gatewayToken.Length})"); + } + catch (Exception ex) + { + Trace.TraceError($"Failed to generate RDP token: {ex.Message}"); + Trace.TraceInformation("Make sure tokengen server is running:"); + Trace.TraceInformation($" cargo run --manifest-path tools/tokengen/Cargo.toml -- server"); + throw; + } + } + + // Connect via gateway - destination needs "hostname:port" format for RDCleanPath + string destination = $"{server}:{port}"; + + var (gatewayRes, gatewayFramed) = await RDCleanPathConnection.ConnectRDCleanPath( + config, gatewayUrl, gatewayToken!, destination, null, factory); + res = gatewayRes; + this._framed = new Framed(gatewayFramed.GetInner().Item1); + + Trace.TraceInformation("=== GATEWAY CONNECTION SUCCESSFUL ==="); + } + else + { + Trace.TraceInformation("=== DIRECT MODE ==="); + + // Direct connection (original behavior) + var (directRes, directFramed) = await Connection.Connect(config, server, factory, port); + res = directRes; + this._framed = new Framed(directFramed.GetInner().Item1); + + Trace.TraceInformation("=== DIRECT CONNECTION SUCCESSFUL ==="); + } + + this._decodedImage = DecodedImage.New(PixelFormat.RgbA32, res.GetDesktopSize().GetWidth(), + res.GetDesktopSize().GetHeight()); + this._activeStage = ActiveStage.New(res); + ReadPduAndProcessActiveStage(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + HandleClipboardEvents(); + } + } + catch (Exception ex) + { + Trace.TraceError($"Connection failed: {ex.Message}"); + Trace.TraceError($"Stack trace: {ex.StackTrace}"); + throw; } }); } @@ -260,12 +358,16 @@ private void HandleClipboardEvents() }); } - private static Config BuildConfig(string username, string password, string domain, int width, int height) + private static Config BuildConfig(string username, string password, string? domain, int width, int height) { ConfigBuilder configBuilder = ConfigBuilder.New(); configBuilder.WithUsernameAndPassword(username, password); - configBuilder.SetDomain(domain); + if (domain != null) + { + configBuilder.SetDomain(domain); + } + configBuilder.SetDesktopSize((ushort)height, (ushort)width); configBuilder.SetClientName("IronRdp"); configBuilder.SetClientDir("C:\\"); @@ -418,7 +520,7 @@ private async Task HandleActiveStageOutput(ActiveStageOutputIterator outpu var writeBuf = WriteBuf.New(); while (true) { - await Connection.SingleSequenceStep(activationSequence, writeBuf,_framed!); + await Connection.SingleSequenceStep(activationSequence, writeBuf, _framed!); if (activationSequence.GetState().GetType() != ConnectionActivationStateType.Finalized) continue; diff --git a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/TokenGenerator.cs b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/TokenGenerator.cs new file mode 100644 index 000000000..00946c604 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/TokenGenerator.cs @@ -0,0 +1,192 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Devolutions.IronRdp.AvaloniaExample; + +/// +/// Client for requesting JWT tokens from a Devolutions Gateway tokengen server. +/// +public class TokenGenerator : IDisposable +{ + private readonly HttpClient _client; + private readonly string _tokengenUrl; + + /// + /// Creates a new TokenGenerator instance. + /// + /// The base URL of the tokengen server (e.g., "http://localhost:8080") + public TokenGenerator(string tokengenUrl = "http://localhost:8080") + { + _tokengenUrl = tokengenUrl; + _client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + + /// + /// Generates an RDP token with credential injection for gateway-based connections. + /// + /// Destination RDP server (e.g., "10.10.0.3:3389") + /// Gateway proxy username + /// Gateway proxy password + /// Destination RDP server username + /// Destination RDP server password + /// Optional session UUID + /// Token validity in seconds (default: 3600) + /// A JWT token string + public async Task GenerateRdpTlsToken( + string dstHost, + string proxyUser, + string proxyPassword, + string destUser, + string destPassword, + string? jetAid = null, + int validityDuration = 3600) + { + var request = new RdpTlsTokenRequest + { + DstHst = dstHost, + PrxUsr = proxyUser, + PrxPwd = proxyPassword, + DstUsr = destUser, + DstPwd = destPassword, + JetAid = jetAid, + ValidityDuration = validityDuration + }; + + try + { + var response = await _client.PostAsJsonAsync($"{_tokengenUrl}/rdp_tls", request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + if (result?.Token == null) + { + throw new Exception("Token generation failed: Empty response"); + } + + return result.Token; + } + catch (HttpRequestException ex) + { + throw new Exception($"Failed to connect to tokengen server at {_tokengenUrl}: {ex.Message}", ex); + } + catch (TaskCanceledException ex) + { + throw new Exception($"Token generation request timed out: {ex.Message}", ex); + } + } + + /// + /// Generates a forward mode token for simple RDP forwarding without credential injection. + /// + /// Destination host + /// Application protocol (default: "rdp") + /// Enable recording + /// Token validity in seconds (default: 3600) + /// A JWT token string + public async Task GenerateForwardToken( + string dstHost, + string jetAp = "rdp", + bool jetRec = false, + int validityDuration = 3600) + { + var request = new ForwardTokenRequest + { + DstHst = dstHost, + JetAp = jetAp, + JetRec = jetRec, + ValidityDuration = validityDuration + }; + + try + { + var response = await _client.PostAsJsonAsync($"{_tokengenUrl}/forward", request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + if (result?.Token == null) + { + throw new Exception("Token generation failed: Empty response"); + } + + return result.Token; + } + catch (HttpRequestException ex) + { + throw new Exception($"Failed to connect to tokengen server at {_tokengenUrl}: {ex.Message}", ex); + } + } + + /// + /// Checks if the tokengen server is reachable. + /// + /// True if server is reachable, false otherwise + public async Task IsServerReachable() + { + try + { + var response = await _client.GetAsync(_tokengenUrl); + return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotFound; + } + catch + { + return false; + } + } + + public void Dispose() + { + _client?.Dispose(); + } + + // Request/Response DTOs + private class RdpTlsTokenRequest + { + [JsonPropertyName("dst_hst")] + public string DstHst { get; set; } = string.Empty; + + [JsonPropertyName("prx_usr")] + public string PrxUsr { get; set; } = string.Empty; + + [JsonPropertyName("prx_pwd")] + public string PrxPwd { get; set; } = string.Empty; + + [JsonPropertyName("dst_usr")] + public string DstUsr { get; set; } = string.Empty; + + [JsonPropertyName("dst_pwd")] + public string DstPwd { get; set; } = string.Empty; + + [JsonPropertyName("jet_aid")] + public string? JetAid { get; set; } + + [JsonPropertyName("validity_duration")] + public int ValidityDuration { get; set; } + } + + private class ForwardTokenRequest + { + [JsonPropertyName("dst_hst")] + public string DstHst { get; set; } = string.Empty; + + [JsonPropertyName("jet_ap")] + public string JetAp { get; set; } = "rdp"; + + [JsonPropertyName("jet_rec")] + public bool JetRec { get; set; } + + [JsonPropertyName("validity_duration")] + public int ValidityDuration { get; set; } + } + + private class TokenResponse + { + [JsonPropertyName("token")] + public string? Token { get; set; } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj index 1ef474af5..61014ed13 100644 --- a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj +++ b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj @@ -16,7 +16,7 @@ https://learn.microsoft.com/en-us/dotnet/api/system.drawing?view=net-8.0 --> - + diff --git a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs index b685455ca..3c85f2587 100644 --- a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs +++ b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs @@ -23,7 +23,7 @@ static async Task Main(string[] args) try { - var (res, framed) = await Connection.Connect(buildConfig(serverName, username, password, domain, 1980, 1080), serverName, null); + var (res, framed) = await Connection.Connect(buildConfig(username, password, domain, 1980, 1080), serverName, null); var decodedImage = DecodedImage.New(PixelFormat.RgbA32, res.GetDesktopSize().GetWidth(), res.GetDesktopSize().GetHeight()); var activeState = ActiveStage.New(res); var keepLooping = true; @@ -175,7 +175,7 @@ static void PrintHelp() Console.WriteLine(" --help Show this message and exit."); } - private static Config buildConfig(string servername, string username, string password, string domain, int width, int height) + private static Config buildConfig(string username, string password, string domain, int width, int height) { ConfigBuilder configBuilder = ConfigBuilder.New(); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/CertificateChainIterator.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/CertificateChainIterator.cs new file mode 100644 index 000000000..9830d732d --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/CertificateChainIterator.cs @@ -0,0 +1,109 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class CertificateChainIterator: IDisposable +{ + private unsafe Raw.CertificateChainIterator* _inner; + + /// + /// Creates a managed CertificateChainIterator from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe CertificateChainIterator(Raw.CertificateChainIterator* handle) + { + _inner = handle; + } + + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8? Next() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("CertificateChainIterator"); + } + Raw.VecU8* retVal = Raw.CertificateChainIterator.Next(_inner); + if (retVal == null) + { + return null; + } + return new VecU8(retVal); + } + } + + public nuint Len() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("CertificateChainIterator"); + } + nuint retVal = Raw.CertificateChainIterator.Len(_inner); + return retVal; + } + } + + public bool IsEmpty() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("CertificateChainIterator"); + } + bool retVal = Raw.CertificateChainIterator.IsEmpty(_inner); + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.CertificateChainIterator* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.CertificateChainIterator.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~CertificateChainIterator() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs index ec2769f3b..c0e3af52f 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs @@ -29,6 +29,13 @@ public unsafe Log(Raw.Log* handle) _inner = handle; } + /// + /// # Panics + /// + /// + /// - Panics if log directory creation fails. + /// - Panics if tracing initialization fails. + /// public static void InitWithEnv() { unsafe diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathDetectionResult.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathDetectionResult.cs new file mode 100644 index 000000000..644c997e6 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathDetectionResult.cs @@ -0,0 +1,129 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class RDCleanPathDetectionResult: IDisposable +{ + private unsafe Raw.RDCleanPathDetectionResult* _inner; + + public nuint TotalLength + { + get + { + return GetTotalLength(); + } + } + + /// + /// Creates a managed RDCleanPathDetectionResult from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe RDCleanPathDetectionResult(Raw.RDCleanPathDetectionResult* handle) + { + _inner = handle; + } + + public bool IsDetected() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + bool retVal = Raw.RDCleanPathDetectionResult.IsDetected(_inner); + return retVal; + } + } + + public bool IsNotEnoughBytes() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + bool retVal = Raw.RDCleanPathDetectionResult.IsNotEnoughBytes(_inner); + return retVal; + } + } + + public bool IsFailed() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + bool retVal = Raw.RDCleanPathDetectionResult.IsFailed(_inner); + return retVal; + } + } + + /// + public nuint GetTotalLength() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + Raw.RdcleanpathFfiResultUsizeBoxIronRdpError result = Raw.RDCleanPathDetectionResult.GetTotalLength(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + nuint retVal = result.Ok; + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.RDCleanPathDetectionResult* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.RDCleanPathDetectionResult.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~RDCleanPathDetectionResult() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathPdu.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathPdu.cs new file mode 100644 index 000000000..b4c5d6278 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathPdu.cs @@ -0,0 +1,424 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class RDCleanPathPdu: IDisposable +{ + private unsafe Raw.RDCleanPathPdu* _inner; + + public ushort ErrorCode + { + get + { + return GetErrorCode(); + } + } + + public string ErrorMessage + { + get + { + return GetErrorMessage(); + } + } + + public ushort HttpStatusCode + { + get + { + return GetHttpStatusCode(); + } + } + + public string ServerAddr + { + get + { + return GetServerAddr(); + } + } + + public CertificateChainIterator ServerCertChain + { + get + { + return GetServerCertChain(); + } + } + + public RDCleanPathResultType Type + { + get + { + return GetType(); + } + } + + public VecU8 X224Response + { + get + { + return GetX224Response(); + } + } + + /// + /// Creates a managed RDCleanPathPdu from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe RDCleanPathPdu(Raw.RDCleanPathPdu* handle) + { + _inner = handle; + } + + /// + /// Creates a new RDCleanPath request PDU + /// + /// + /// # Arguments + /// * `x224_pdu` - The X.224 Connection Request PDU bytes + /// * `destination` - The destination RDP server address (e.g., "10.10.0.3:3389") + /// * `proxy_auth` - The JWT authentication token + /// * `pcb` - Optional preconnection blob (for Hyper-V VM connections, empty string if not needed) + /// + /// + /// + /// A RDCleanPathPdu allocated on Rust side. + /// + public static RDCleanPathPdu NewRequest(byte[] x224Pdu, string destination, string proxyAuth, string pcb) + { + unsafe + { + byte[] destinationBuf = DiplomatUtils.StringToUtf8(destination); + byte[] proxyAuthBuf = DiplomatUtils.StringToUtf8(proxyAuth); + byte[] pcbBuf = DiplomatUtils.StringToUtf8(pcb); + nuint x224PduLength = (nuint)x224Pdu.Length; + nuint destinationBufLength = (nuint)destinationBuf.Length; + nuint proxyAuthBufLength = (nuint)proxyAuthBuf.Length; + nuint pcbBufLength = (nuint)pcbBuf.Length; + fixed (byte* x224PduPtr = x224Pdu) + { + fixed (byte* destinationBufPtr = destinationBuf) + { + fixed (byte* proxyAuthBufPtr = proxyAuthBuf) + { + fixed (byte* pcbBufPtr = pcbBuf) + { + Raw.RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError result = Raw.RDCleanPathPdu.NewRequest(x224PduPtr, x224PduLength, destinationBufPtr, destinationBufLength, proxyAuthBufPtr, proxyAuthBufLength, pcbBufPtr, pcbBufLength); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.RDCleanPathPdu* retVal = result.Ok; + return new RDCleanPathPdu(retVal); + } + } + } + } + } + } + + /// + /// Decodes a RDCleanPath PDU from DER-encoded bytes + /// + /// + /// + /// A RDCleanPathPdu allocated on Rust side. + /// + public static RDCleanPathPdu FromDer(byte[] bytes) + { + unsafe + { + nuint bytesLength = (nuint)bytes.Length; + fixed (byte* bytesPtr = bytes) + { + Raw.RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError result = Raw.RDCleanPathPdu.FromDer(bytesPtr, bytesLength); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.RDCleanPathPdu* retVal = result.Ok; + return new RDCleanPathPdu(retVal); + } + } + } + + /// + /// Encodes the RDCleanPath PDU to DER-encoded bytes + /// + /// + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8 ToDer() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultBoxVecU8BoxIronRdpError result = Raw.RDCleanPathPdu.ToDer(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.VecU8* retVal = result.Ok; + return new VecU8(retVal); + } + } + + /// + /// Detects if the bytes contain a valid RDCleanPath PDU and returns detection result + /// + /// + /// A RDCleanPathDetectionResult allocated on Rust side. + /// + public static RDCleanPathDetectionResult Detect(byte[] bytes) + { + unsafe + { + nuint bytesLength = (nuint)bytes.Length; + fixed (byte* bytesPtr = bytes) + { + Raw.RDCleanPathDetectionResult* retVal = Raw.RDCleanPathPdu.Detect(bytesPtr, bytesLength); + return new RDCleanPathDetectionResult(retVal); + } + } + } + + /// + /// Gets the type of this RDCleanPath PDU + /// + /// + /// + /// A RDCleanPathResultType allocated on C# side. + /// + public RDCleanPathResultType GetType() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError result = Raw.RDCleanPathPdu.GetType(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.RDCleanPathResultType retVal = result.Ok; + return (RDCleanPathResultType)retVal; + } + } + + /// + /// Gets the X.224 connection response bytes (for Response or NegotiationError variants) + /// + /// + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8 GetX224Response() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultBoxVecU8BoxIronRdpError result = Raw.RDCleanPathPdu.GetX224Response(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.VecU8* retVal = result.Ok; + return new VecU8(retVal); + } + } + + /// + /// Gets the server certificate chain (for Response variant) + /// Returns a vector iterator of certificate bytes + /// + /// + /// + /// A CertificateChainIterator allocated on Rust side. + /// + public CertificateChainIterator GetServerCertChain() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError result = Raw.RDCleanPathPdu.GetServerCertChain(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.CertificateChainIterator* retVal = result.Ok; + return new CertificateChainIterator(retVal); + } + } + + /// + /// Gets the server address string (for Response variant) + /// + public void GetServerAddr(DiplomatWriteable writeable) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RDCleanPathPdu.GetServerAddr(_inner, &writeable); + } + } + + /// + /// Gets the server address string (for Response variant) + /// + public string GetServerAddr() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + DiplomatWriteable writeable = new DiplomatWriteable(); + Raw.RDCleanPathPdu.GetServerAddr(_inner, &writeable); + string retVal = writeable.ToUnicode(); + writeable.Dispose(); + return retVal; + } + } + + /// + /// Gets error message (for GeneralError variant) + /// + public void GetErrorMessage(DiplomatWriteable writeable) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RDCleanPathPdu.GetErrorMessage(_inner, &writeable); + } + } + + /// + /// Gets error message (for GeneralError variant) + /// + public string GetErrorMessage() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + DiplomatWriteable writeable = new DiplomatWriteable(); + Raw.RDCleanPathPdu.GetErrorMessage(_inner, &writeable); + string retVal = writeable.ToUnicode(); + writeable.Dispose(); + return retVal; + } + } + + /// + /// Gets the error code (for GeneralError variant) + /// + /// + public ushort GetErrorCode() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultU16BoxIronRdpError result = Raw.RDCleanPathPdu.GetErrorCode(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + ushort retVal = result.Ok; + return retVal; + } + } + + /// + /// Gets the HTTP status code if present (for GeneralError variant) + /// Returns error if not present or not a GeneralError variant + /// + /// + public ushort GetHttpStatusCode() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultU16BoxIronRdpError result = Raw.RDCleanPathPdu.GetHttpStatusCode(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + ushort retVal = result.Ok; + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.RDCleanPathPdu* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.RDCleanPathPdu.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~RDCleanPathPdu() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathResultType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathResultType.cs new file mode 100644 index 000000000..4145574e1 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathResultType.cs @@ -0,0 +1,20 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public enum RDCleanPathResultType +{ + Request = 0, + Response = 1, + GeneralError = 2, + NegotiationError = 3, +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawCertificateChainIterator.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawCertificateChainIterator.cs new file mode 100644 index 000000000..c451aa991 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawCertificateChainIterator.cs @@ -0,0 +1,31 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct CertificateChainIterator +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_next", ExactSpelling = true)] + public static unsafe extern VecU8* Next(CertificateChainIterator* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_len", ExactSpelling = true)] + public static unsafe extern nuint Len(CertificateChainIterator* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_is_empty", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsEmpty(CertificateChainIterator* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(CertificateChainIterator* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs index e1f649b99..404827024 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs @@ -16,6 +16,13 @@ public partial struct Log { private const string NativeLib = "DevolutionsIronRdp"; + /// + /// # Panics + /// + /// + /// - Panics if log directory creation fails. + /// - Panics if tracing initialization fails. + /// [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Log_init_with_env", ExactSpelling = true)] public static unsafe extern void InitWithEnv(); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathDetectionResult.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathDetectionResult.cs new file mode 100644 index 000000000..a5f7d88d0 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathDetectionResult.cs @@ -0,0 +1,36 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RDCleanPathDetectionResult +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_is_detected", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsDetected(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_is_not_enough_bytes", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsNotEnoughBytes(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_is_failed", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsFailed(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_get_total_length", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultUsizeBoxIronRdpError GetTotalLength(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(RDCleanPathDetectionResult* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathPdu.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathPdu.cs new file mode 100644 index 000000000..5710356a1 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathPdu.cs @@ -0,0 +1,96 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RDCleanPathPdu +{ + private const string NativeLib = "DevolutionsIronRdp"; + + /// + /// Creates a new RDCleanPath request PDU + /// + /// + /// # Arguments + /// * `x224_pdu` - The X.224 Connection Request PDU bytes + /// * `destination` - The destination RDP server address (e.g., "10.10.0.3:3389") + /// * `proxy_auth` - The JWT authentication token + /// * `pcb` - Optional preconnection blob (for Hyper-V VM connections, empty string if not needed) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_new_request", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError NewRequest(byte* x224Pdu, nuint x224PduSz, byte* destination, nuint destinationSz, byte* proxyAuth, nuint proxyAuthSz, byte* pcb, nuint pcbSz); + + /// + /// Decodes a RDCleanPath PDU from DER-encoded bytes + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_from_der", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError FromDer(byte* bytes, nuint bytesSz); + + /// + /// Encodes the RDCleanPath PDU to DER-encoded bytes + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_to_der", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxVecU8BoxIronRdpError ToDer(RDCleanPathPdu* self); + + /// + /// Detects if the bytes contain a valid RDCleanPath PDU and returns detection result + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_detect", ExactSpelling = true)] + public static unsafe extern RDCleanPathDetectionResult* Detect(byte* bytes, nuint bytesSz); + + /// + /// Gets the type of this RDCleanPath PDU + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_type", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError GetType(RDCleanPathPdu* self); + + /// + /// Gets the X.224 connection response bytes (for Response or NegotiationError variants) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_x224_response", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxVecU8BoxIronRdpError GetX224Response(RDCleanPathPdu* self); + + /// + /// Gets the server certificate chain (for Response variant) + /// Returns a vector iterator of certificate bytes + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_server_cert_chain", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError GetServerCertChain(RDCleanPathPdu* self); + + /// + /// Gets the server address string (for Response variant) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_server_addr", ExactSpelling = true)] + public static unsafe extern void GetServerAddr(RDCleanPathPdu* self, DiplomatWriteable* writeable); + + /// + /// Gets error message (for GeneralError variant) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_error_message", ExactSpelling = true)] + public static unsafe extern void GetErrorMessage(RDCleanPathPdu* self, DiplomatWriteable* writeable); + + /// + /// Gets the error code (for GeneralError variant) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_error_code", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultU16BoxIronRdpError GetErrorCode(RDCleanPathPdu* self); + + /// + /// Gets the HTTP status code if present (for GeneralError variant) + /// Returns error if not present or not a GeneralError variant + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_http_status_code", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultU16BoxIronRdpError GetHttpStatusCode(RDCleanPathPdu* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(RDCleanPathPdu* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathResultType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathResultType.cs new file mode 100644 index 000000000..393d65092 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathResultType.cs @@ -0,0 +1,20 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +public enum RDCleanPathResultType +{ + Request = 0, + Response = 1, + GeneralError = 2, + NegotiationError = 3, +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError.cs new file mode 100644 index 000000000..01cb35908 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal CertificateChainIterator* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe CertificateChainIterator* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError.cs new file mode 100644 index 000000000..c9f33a5da --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal RDCleanPathPdu* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe RDCleanPathPdu* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxVecU8BoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxVecU8BoxIronRdpError.cs new file mode 100644 index 000000000..7cce2e6ad --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxVecU8BoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultBoxVecU8BoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal VecU8* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe VecU8* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError.cs new file mode 100644 index 000000000..6a9c6e8a9 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal RDCleanPathResultType ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe RDCleanPathResultType Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultU16BoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultU16BoxIronRdpError.cs new file mode 100644 index 000000000..3eaf32007 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultU16BoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultU16BoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal ushort ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe ushort Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultUsizeBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultUsizeBoxIronRdpError.cs new file mode 100644 index 000000000..eecbdaea4 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultUsizeBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultUsizeBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal nuint ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe nuint Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs b/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs index 0a03fd6e0..497e70ab2 100644 --- a/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs +++ b/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs @@ -11,28 +11,17 @@ public static class Connection { var client = await CreateTcpConnection(serverName, port); string clientAddr = client.Client.LocalEndPoint.ToString(); - Console.WriteLine(clientAddr); + System.Diagnostics.Debug.WriteLine(clientAddr); var framed = new Framed(client.GetStream()); var connector = ClientConnector.New(config, clientAddr); - connector.WithDynamicChannelDisplayControl(); - var dvcPipeProxy = config.DvcPipeProxy; - if (dvcPipeProxy != null) - { - connector.WithDynamicChannelPipeProxy(dvcPipeProxy); - } - - if (factory != null) - { - var cliprdr = factory.BuildCliprdr(); - connector.AttachStaticCliprdr(cliprdr); - } + ConnectionHelpers.SetupConnector(connector, config, factory); await ConnectBegin(framed, connector); var (serverPublicKey, framedSsl) = await SecurityUpgrade(framed, connector); - var result = await ConnectFinalize(serverName, connector, serverPublicKey, framedSsl); + var result = await ConnectionHelpers.ConnectFinalize(serverName, connector, serverPublicKey, framedSsl); return (result, framedSsl); } @@ -67,78 +56,11 @@ private static async Task ConnectBegin(Framed framed, ClientConne } } - - private static async Task ConnectFinalize(string serverName, ClientConnector connector, - byte[] serverPubKey, Framed framedSsl) - { - var writeBuf2 = WriteBuf.New(); - if (connector.ShouldPerformCredssp()) - { - await PerformCredsspSteps(connector, serverName, writeBuf2, framedSsl, serverPubKey); - } - - while (!connector.GetDynState().IsTerminal()) - { - await SingleSequenceStep(connector, writeBuf2, framedSsl); - } - - ClientConnectorState state = connector.ConsumeAndCastToClientConnectorState(); - - if (state.GetEnumType() == ClientConnectorStateType.Connected) - { - return state.GetConnectedResult(); - } - else - { - throw new IronRdpLibException(IronRdpLibExceptionType.ConnectionFailed, "Connection failed"); - } - } - - private static async Task PerformCredsspSteps(ClientConnector connector, string serverName, WriteBuf writeBuf, - Framed framedSsl, byte[] serverpubkey) - { - var credsspSequenceInitResult = CredsspSequence.Init(connector, serverName, serverpubkey, null); - var credsspSequence = credsspSequenceInitResult.GetCredsspSequence(); - var tsRequest = credsspSequenceInitResult.GetTsRequest(); - var tcpClient = new TcpClient(); - while (true) - { - var generator = credsspSequence.ProcessTsRequest(tsRequest); - var clientState = await ResolveGenerator(generator, tcpClient); - writeBuf.Clear(); - var written = credsspSequence.HandleProcessResult(clientState, writeBuf); - - if (written.GetSize().IsSome()) - { - var actualSize = (int)written.GetSize().Get(); - var response = new byte[actualSize]; - writeBuf.ReadIntoBuf(response); - await framedSsl.Write(response); - } - - var pduHint = credsspSequence.NextPduHint(); - if (pduHint == null) - { - break; - } - - var pdu = await framedSsl.ReadByHint(pduHint); - var decoded = credsspSequence.DecodeServerMessage(pdu); - - // Don't remove, DecodeServerMessage is generated, and it can return null - if (null == decoded) - { - break; - } - - tsRequest = decoded; - } - } - - private static async Task ResolveGenerator(CredsspProcessGenerator generator, TcpClient tcpClient) + internal static async Task ResolveGenerator(CredsspProcessGenerator generator, TcpClient tcpClient) { var state = generator.Start(); NetworkStream? stream = null; + while (true) { if (state.IsSuspended()) @@ -147,16 +69,17 @@ private static async Task ResolveGenerator(CredsspProcessGenerator var protocol = request.GetProtocol(); var url = request.GetUrl(); var data = request.GetData(); - if (null == stream) - { - url = url.Replace("tcp://", ""); - var split = url.Split(":"); - await tcpClient.ConnectAsync(split[0], int.Parse(split[1])); - stream = tcpClient.GetStream(); - } if (protocol == NetworkRequestProtocol.Tcp) { + if (null == stream) + { + url = url.Replace("tcp://", ""); + var split = url.Split(":"); + await tcpClient.ConnectAsync(split[0], int.Parse(split[1])); + stream = tcpClient.GetStream(); + } + stream.Write(Utils.VecU8ToByte(data)); var readBuf = new byte[8096]; var readlen = await stream.ReadAsync(readBuf, 0, readBuf.Length); @@ -166,13 +89,29 @@ private static async Task ResolveGenerator(CredsspProcessGenerator } else { - throw new Exception("Unimplemented protocol"); + throw new Exception($"Unimplemented protocol: {protocol}"); + } + } + else if (state.IsCompleted()) + { + try + { + var clientState = state.GetClientStateIfCompleted(); + return clientState; + } + catch (IronRdpException ex) + { + System.Diagnostics.Debug.WriteLine($"[ResolveGenerator] Error getting client state: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"[ResolveGenerator] Error kind: {ex.Inner?.Kind}"); + System.Diagnostics.Debug.WriteLine($"[ResolveGenerator] Stack trace: {ex.StackTrace}"); + throw; } } else { - var clientState = state.GetClientStateIfCompleted(); - return clientState; + var errorMsg = $"[ResolveGenerator] Generator state is neither suspended nor completed. IsSuspended={state.IsSuspended()}, IsCompleted={state.IsCompleted()}"; + System.Diagnostics.Debug.WriteLine(errorMsg); + throw new InvalidOperationException(errorMsg); } } } diff --git a/ffi/dotnet/Devolutions.IronRdp/src/ConnectionHelpers.cs b/ffi/dotnet/Devolutions.IronRdp/src/ConnectionHelpers.cs new file mode 100644 index 000000000..d57aaaca5 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/src/ConnectionHelpers.cs @@ -0,0 +1,121 @@ +using System.Net.Sockets; + +namespace Devolutions.IronRdp; + +/// +/// Internal helper class providing shared connection logic for both direct and RDCleanPath connections. +/// +internal static class ConnectionHelpers +{ + /// + /// Sets up common connector configuration including dynamic channels and clipboard. + /// + internal static void SetupConnector(ClientConnector connector, Config config, CliprdrBackendFactory? factory) + { + connector.WithDynamicChannelDisplayControl(); + + var dvcPipeProxy = config.DvcPipeProxy; + if (dvcPipeProxy != null) + { + connector.WithDynamicChannelPipeProxy(dvcPipeProxy); + } + + if (factory != null) + { + var cliprdr = factory.BuildCliprdr(); + connector.AttachStaticCliprdr(cliprdr); + } + } + + /// + /// Performs CredSSP authentication steps over any stream type. + /// + internal static async Task PerformCredsspSteps( + ClientConnector connector, + string serverName, + WriteBuf writeBuf, + Framed framed, + byte[] serverpubkey) where T : Stream + { + // Extract hostname from "hostname:port" format if needed + // CredSSP needs just the hostname for the service principal name (TERMSRV/hostname) + var hostname = serverName; + var colonIndex = serverName.IndexOf(':'); + if (colonIndex > 0) + { + hostname = serverName.Substring(0, colonIndex); + } + + var credsspSequenceInitResult = CredsspSequence.Init(connector, hostname, serverpubkey, null); + var credsspSequence = credsspSequenceInitResult.GetCredsspSequence(); + var tsRequest = credsspSequenceInitResult.GetTsRequest(); + var tcpClient = new TcpClient(); + + while (true) + { + var generator = credsspSequence.ProcessTsRequest(tsRequest); + var clientState = await Connection.ResolveGenerator(generator, tcpClient); + writeBuf.Clear(); + var written = credsspSequence.HandleProcessResult(clientState, writeBuf); + + if (written.GetSize().IsSome()) + { + var actualSize = (int)written.GetSize().Get(); + var response = new byte[actualSize]; + writeBuf.ReadIntoBuf(response); + await framed.Write(response); + } + + var pduHint = credsspSequence.NextPduHint(); + if (pduHint == null) + { + break; + } + + var pdu = await framed.ReadByHint(pduHint); + var decoded = credsspSequence.DecodeServerMessage(pdu); + + // Don't remove, DecodeServerMessage is generated, and it can return null + if (null == decoded) + { + break; + } + + tsRequest = decoded; + } + } + + /// + /// Finalizes the RDP connection after security upgrade, performing CredSSP if needed + /// and completing the connection sequence. + /// + internal static async Task ConnectFinalize( + string serverName, + ClientConnector connector, + byte[] serverPubKey, + Framed framedSsl) where T : Stream + { + var writeBuf = WriteBuf.New(); + + if (connector.ShouldPerformCredssp()) + { + await PerformCredsspSteps(connector, serverName, writeBuf, framedSsl, serverPubKey); + } + + while (!connector.GetDynState().IsTerminal()) + { + await Connection.SingleSequenceStep(connector, writeBuf, framedSsl); + } + + ClientConnectorState state = connector.ConsumeAndCastToClientConnectorState(); + + if (state.GetEnumType() == ClientConnectorStateType.Connected) + { + return state.GetConnectedResult(); + } + else + { + throw new IronRdpLibException(IronRdpLibExceptionType.ConnectionFailed, "Connection failed"); + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs b/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs index f03530f3d..2e8772e79 100644 --- a/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs +++ b/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs @@ -134,4 +134,45 @@ public async Task ReadByHint(PduHint pduHint) } } } + + /// + /// Reads data from the buffer based on a custom PDU hint function. + /// + /// A custom hint object implementing IPduHint interface. + /// An asynchronous task that represents the operation. The task result contains the read data as a byte array. + public async Task ReadByHint(IPduHint customHint) + { + while (true) + { + var result = customHint.FindSize(this._buffer.ToArray()); + if (result.HasValue) + { + return await this.ReadExact((nuint)result.Value.Item2); + } + else + { + var len = await this.Read(); + if (len == 0) + { + throw new Exception("EOF"); + } + } + } + } +} + +/// +/// Interface for custom PDU hint implementations. +/// +public interface IPduHint +{ + /// + /// Finds the size of a PDU in the given byte array. + /// + /// The byte array to analyze. + /// + /// A tuple (detected, size) if PDU is detected, null if more bytes are needed. + /// Throws exception if invalid PDU is detected. + /// + (bool, int)? FindSize(byte[] bytes); } \ No newline at end of file diff --git a/ffi/dotnet/Devolutions.IronRdp/src/RDCleanPathConnection.cs b/ffi/dotnet/Devolutions.IronRdp/src/RDCleanPathConnection.cs new file mode 100644 index 000000000..725c0525f --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/src/RDCleanPathConnection.cs @@ -0,0 +1,200 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.IronRdp; + +/// +/// Provides methods for connecting to RDP servers through an RDCleanPath-compatible gateway +/// (such as Devolutions Gateway or Cloudflare) using WebSocket. +/// +public static class RDCleanPathConnection +{ + /// + /// Connects to an RDP server through an RDCleanPath-compatible gateway using WebSocket. + /// + /// The RDP connection configuration + /// The WebSocket URL to the RDCleanPath gateway (e.g., "ws://localhost:7171/jet/rdp") + /// The JWT authentication token for the RDCleanPath gateway + /// The destination RDP server address (e.g., "10.10.0.3:3389") + /// Optional preconnection blob for Hyper-V VM connections + /// Optional clipboard backend factory + /// A tuple containing the connection result and framed WebSocket stream + public static async Task<(ConnectionResult, Framed)> ConnectRDCleanPath( + Config config, + string gatewayUrl, + string authToken, + string destination, + string? pcb = null, + CliprdrBackendFactory? factory = null) + { + // Step 1: Connect WebSocket to gateway + System.Diagnostics.Debug.WriteLine($"Connecting to gateway at {gatewayUrl}..."); + var ws = await WebSocketStream.ConnectAsync(new Uri(gatewayUrl)); + var framed = new Framed(ws); + + // Step 2: Get client local address from the WebSocket connection + // This mimics Rust: let client_addr = socket.local_addr()?; + string clientAddr = ws.ClientAddr; + System.Diagnostics.Debug.WriteLine($"Client local address: {clientAddr}"); + + // Step 3: Setup ClientConnector + var connector = ClientConnector.New(config, clientAddr); + ConnectionHelpers.SetupConnector(connector, config, factory); + + // Step 4: Perform RDCleanPath handshake + System.Diagnostics.Debug.WriteLine("Performing RDCleanPath handshake..."); + var (serverPublicKey, framedAfterHandshake) = await ConnectRdCleanPath( + framed, connector, destination, authToken, pcb ?? ""); + + // Step 5: Mark security upgrade as done (WebSocket already has TLS) + connector.MarkSecurityUpgradeAsDone(); + + // Step 6: Finalize connection + System.Diagnostics.Debug.WriteLine("Finalizing RDP connection..."); + var result = await ConnectionHelpers.ConnectFinalize(destination, connector, serverPublicKey, framedAfterHandshake); + + System.Diagnostics.Debug.WriteLine("Gateway connection established successfully!"); + return (result, framedAfterHandshake); + } + + /// + /// Performs the RDCleanPath handshake with the RDCleanPath-compatible gateway. + /// + private static async Task<(byte[], Framed)> ConnectRdCleanPath( + Framed framed, + ClientConnector connector, + string destination, + string authToken, + string pcb) + { + var writeBuf = WriteBuf.New(); + + // Step 1: Generate X.224 Connection Request + System.Diagnostics.Debug.WriteLine("Generating X.224 Connection Request..."); + var written = connector.StepNoInput(writeBuf); + var x224PduSize = (int)written.GetSize().Get(); + var x224Pdu = new byte[x224PduSize]; + writeBuf.ReadIntoBuf(x224Pdu); + + // Step 2: Create and send RDCleanPath Request + System.Diagnostics.Debug.WriteLine($"Sending RDCleanPath request to {destination}..."); + var rdCleanPathReq = RDCleanPathPdu.NewRequest(x224Pdu, destination, authToken, pcb); + var reqBytes = rdCleanPathReq.ToDer(); + var reqBytesArray = new byte[reqBytes.GetSize()]; + reqBytes.Fill(reqBytesArray); + await framed.Write(reqBytesArray); + + // Step 3: Read RDCleanPath Response + System.Diagnostics.Debug.WriteLine("Waiting for RDCleanPath response..."); + var respBytes = await framed.ReadByHint(new RDCleanPathHint()); + var rdCleanPathResp = RDCleanPathPdu.FromDer(respBytes); + + // Step 4: Determine response type and handle accordingly + var resultType = rdCleanPathResp.GetType(); + + if (resultType == RDCleanPathResultType.Response) + { + System.Diagnostics.Debug.WriteLine("RDCleanPath handshake successful!"); + + // Extract X.224 response + var x224Response = rdCleanPathResp.GetX224Response(); + var x224ResponseBytes = new byte[x224Response.GetSize()]; + x224Response.Fill(x224ResponseBytes); + + // Process X.224 response with connector + writeBuf.Clear(); + connector.Step(x224ResponseBytes, writeBuf); + + // Extract server public key from certificate chain + var certChain = rdCleanPathResp.GetServerCertChain(); + if (certChain.IsEmpty()) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "Server certificate chain is empty"); + } + + var firstCert = certChain.Next(); + if (firstCert == null) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "Failed to get first certificate from chain"); + } + + var certBytes = new byte[firstCert.GetSize()]; + firstCert.Fill(certBytes); + + var serverPublicKey = ExtractPublicKeyFromX509(certBytes); + + System.Diagnostics.Debug.WriteLine($"Extracted server public key (length: {serverPublicKey.Length})"); + + return (serverPublicKey, framed); + } + else if (resultType == RDCleanPathResultType.GeneralError) + { + var errorCode = rdCleanPathResp.GetErrorCode(); + var errorMessage = rdCleanPathResp.GetErrorMessage(); + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + $"RDCleanPath error (code {errorCode}): {errorMessage}"); + } + else if (resultType == RDCleanPathResultType.NegotiationError) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "RDCleanPath negotiation error: Server rejected connection parameters"); + } + else + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + $"Unexpected RDCleanPath response type: {resultType}"); + } + } + + /// + /// Extracts the public key from an X.509 certificate in DER format. + /// + private static byte[] ExtractPublicKeyFromX509(byte[] certDer) + { + try + { + var cert = new X509Certificate2(certDer); + return cert.GetPublicKey(); + } + catch (Exception ex) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + $"Failed to extract public key from certificate: {ex.Message}"); + } + } +} + +/// +/// PDU hint for detecting RDCleanPath PDUs in the stream. +/// +public class RDCleanPathHint : IPduHint +{ + public (bool, int)? FindSize(byte[] bytes) + { + var detection = RDCleanPathPdu.Detect(bytes); + + if (detection.IsDetected()) + { + var totalLength = (int)detection.GetTotalLength(); + return (true, totalLength); + } + + if (detection.IsNotEnoughBytes()) + { + return null; // Need more bytes + } + + // Detection failed + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "Invalid RDCleanPath PDU detected"); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/src/WebsocketStream.cs b/ffi/dotnet/Devolutions.IronRdp/src/WebsocketStream.cs new file mode 100644 index 000000000..5f5281011 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/src/WebsocketStream.cs @@ -0,0 +1,212 @@ +using System; +using System.Buffers; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +public sealed class WebSocketStream : Stream +{ + private readonly ClientWebSocket _ws; + private readonly byte[] _recvBuf; + private int _recvPos; + private int _recvLen; + private bool _remoteClosed; + private bool _disposed; + private readonly string _clientAddr; + + private const int DefaultRecvBufferSize = 64 * 1024; + private const int MaxSendFrame = 16 * 1024; // send in chunks + + private WebSocketStream(ClientWebSocket ws, int receiveBufferSize, string clientAddr) + { + _ws = ws ?? throw new ArgumentNullException(nameof(ws)); + _recvBuf = ArrayPool.Shared.Rent(Math.Max(1024, receiveBufferSize)); + _clientAddr = clientAddr; + } + + public static async Task ConnectAsync( + Uri uri, + ClientWebSocket? ws = null, + int receiveBufferSize = DefaultRecvBufferSize, + CancellationToken ct = default) + { + // Capture the local endpoint from the socket using SocketsHttpHandler.ConnectCallback + // This follows the Rust approach: socket.local_addr() + IPEndPoint? localEndPoint = null; + + var handler = new SocketsHttpHandler + { + ConnectCallback = async (context, cancellationToken) => + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + + // Set TCP_NODELAY (matching Rust: socket.set_nodelay(true)) + socket.NoDelay = true; + + // Connect to the endpoint + await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false); + + // Capture the local endpoint after connection + localEndPoint = socket.LocalEndPoint as IPEndPoint; + + return new NetworkStream(socket, ownsSocket: true); + } + }; + + var invoker = new HttpMessageInvoker(handler); + + ws ??= new ClientWebSocket(); + await ws.ConnectAsync(uri, invoker, ct).ConfigureAwait(false); + + string clientAddr = localEndPoint?.ToString() ?? "127.0.0.1:0"; + + return new WebSocketStream(ws, receiveBufferSize, clientAddr); + } + + public ClientWebSocket Socket => _ws; + + /// + /// Gets the local client address in "IP:port" format. + /// This is the address that was determined when establishing the TCP connection. + /// + public string ClientAddr => _clientAddr; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override void Flush() { /* no-op */ } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count)).GetAwaiter().GetResult(); + + public override async ValueTask ReadAsync( + Memory destination, CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(WebSocketStream)); + if (_remoteClosed) return 0; + if (destination.Length == 0) return 0; + + // Fill local buffer if empty + if (_recvLen == 0) + { + var mem = _recvBuf.AsMemory(); + while (true) + { + var result = await _ws.ReceiveAsync(mem, cancellationToken).ConfigureAwait(false); + + // Close frame → signal EOF + if (result.MessageType == WebSocketMessageType.Close) + { + _remoteClosed = true; + try { await _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "OK", cancellationToken).ConfigureAwait(false); } + catch { /* ignore */ } + return 0; + } + + if (result.MessageType == WebSocketMessageType.Text) + throw new InvalidOperationException("Received TEXT frame; this stream expects BINARY."); + + // Some data arrived + if (result.Count > 0) + { + _recvPos = 0; + _recvLen = result.Count; + break; + } + + // Keep looping if Count == 0 (can happen with pings/keepers) + } + } + + var toCopy = Math.Min(destination.Length, _recvLen); + new ReadOnlySpan(_recvBuf, _recvPos, toCopy).CopyTo(destination.Span); + _recvPos += toCopy; + _recvLen -= toCopy; + + // If we've drained local buffer, try to prefetch next chunk (non-blocking behavior not guaranteed) + if (_recvLen == 0 && _ws.State == WebSocketState.Open) + { + // optional prefetch: not strictly necessary—kept simple + } + + return toCopy; + } + + public override async Task WriteAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + + public override async ValueTask WriteAsync( + ReadOnlyMemory source, CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(WebSocketStream)); + if (_ws.State != WebSocketState.Open) throw new IOException("WebSocket is not open."); + + // Treat each Write* as one complete WebSocket message (Binary). + // Chunk large payloads as continuation frames and set EndOfMessage on the last chunk. + int sent = 0; + while (sent < source.Length) + { + var chunkLen = Math.Min(MaxSendFrame, source.Length - sent); + var chunk = source.Slice(sent, chunkLen); + sent += chunkLen; + + bool end = (sent == source.Length); + await _ws.SendAsync(chunk, WebSocketMessageType.Binary, end, cancellationToken).ConfigureAwait(false); + } + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + try + { + if (_ws.State == WebSocketState.Open) + { + _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None) + .GetAwaiter().GetResult(); + } + } + catch { /* ignore on dispose */ } + _ws.Dispose(); + ArrayPool.Shared.Return(_recvBuf); + } + _disposed = true; + base.Dispose(disposing); + } + +#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + public override async ValueTask DisposeAsync() + { + if (!_disposed) + { + try + { + if (_ws.State == WebSocketState.Open) + await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None).ConfigureAwait(false); + } + catch { /* ignore */ } + _ws.Dispose(); + ArrayPool.Shared.Return(_recvBuf); + _disposed = true; + } + await base.DisposeAsync().ConfigureAwait(false); + } +#endif +} diff --git a/ffi/src/clipboard/mod.rs b/ffi/src/clipboard/mod.rs index e50aa1fec..386e7b855 100644 --- a/ffi/src/clipboard/mod.rs +++ b/ffi/src/clipboard/mod.rs @@ -34,8 +34,8 @@ pub struct FfiClipbarodMessageProxy { impl ironrdp::cliprdr::backend::ClipboardMessageProxy for FfiClipbarodMessageProxy { fn send_clipboard_message(&self, message: ironrdp::cliprdr::backend::ClipboardMessage) { - if let Err(err) = self.sender.send(message) { - error!("Failed to send clipboard message: {:?}", err); + if let Err(error) = self.sender.send(message) { + error!(?error, "Failed to send clipboard message"); } } } diff --git a/ffi/src/connector/mod.rs b/ffi/src/connector/mod.rs index e9226467c..332177af9 100644 --- a/ffi/src/connector/mod.rs +++ b/ffi/src/connector/mod.rs @@ -97,7 +97,7 @@ pub mod ffi { pub fn with_dynamic_channel_display_control(&mut self) -> Result<(), Box> { self.with_dvc(DisplayControlClient::new(|c| { - info!(DisplayCountrolCapabilities = ?c, "DisplayControl capabilities received"); + info!(display_control_capabilities = ?c, "DisplayControl capabilities received"); Ok(Vec::new()) })) } @@ -185,7 +185,7 @@ pub mod ffi { let Some(connector) = self.0.as_ref() else { return Err(ValueConsumedError::for_item("connector").into()); }; - tracing::trace!(pduhint=?connector.next_pdu_hint(), "Reading next PDU hint"); + tracing::trace!(pdu_hint=?connector.next_pdu_hint(), "Reading next PDU hint"); Ok(connector.next_pdu_hint().map(PduHint).map(Box::new)) } diff --git a/ffi/src/error.rs b/ffi/src/error.rs index 9312ba45f..d21c3893e 100644 --- a/ffi/src/error.rs +++ b/ffi/src/error.rs @@ -6,6 +6,7 @@ use ironrdp::connector::ConnectorError; use ironrdp::session::SessionError; #[cfg(target_os = "windows")] use ironrdp_cliprdr_native::WinCliprdrError; +use ironrdp_rdcleanpath::der; use self::ffi::IronRdpErrorKind; @@ -93,6 +94,32 @@ impl From for IronRdpErrorKind { } } +impl From for IronRdpErrorKind { + fn from(_val: der::Error) -> Self { + IronRdpErrorKind::DecodeError + } +} + +impl From for IronRdpErrorKind { + fn from(_val: ironrdp_rdcleanpath::MissingRDCleanPathField) -> Self { + IronRdpErrorKind::Generic + } +} + +pub struct GenericError(pub anyhow::Error); + +impl Display for GenericError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:#}", self.0) + } +} + +impl From for IronRdpErrorKind { + fn from(_val: GenericError) -> Self { + IronRdpErrorKind::Generic + } +} + impl From for Box where T: Into + ToString, @@ -221,11 +248,7 @@ impl IncorrectEnumTypeErrorBuilder { impl Display for IncorrectEnumTypeError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "expected enum variable {}, of enum {}", - self.expected, self.enum_name - ) + write!(f, "expected enum variable {} of enum {}", self.expected, self.enum_name) } } diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 407847ce4..44009cd5a 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -11,6 +11,7 @@ pub mod graphics; pub mod input; pub mod log; pub mod pdu; +pub mod rdcleanpath; pub mod session; pub mod svc; pub mod utils; diff --git a/ffi/src/log.rs b/ffi/src/log.rs index 7f7750a27..f050b1503 100644 --- a/ffi/src/log.rs +++ b/ffi/src/log.rs @@ -23,7 +23,7 @@ pub mod ffi { INIT_LOG.call_once(|| { let log_file = std::env::var(IRONRDP_LOG_PATH).ok(); let log_file = log_file.as_deref(); - setup_logging(log_file).expect("Failed to setup logging"); + setup_logging(log_file).expect("failed to setup logging"); }); } } diff --git a/ffi/src/rdcleanpath.rs b/ffi/src/rdcleanpath.rs new file mode 100644 index 000000000..a842c747b --- /dev/null +++ b/ffi/src/rdcleanpath.rs @@ -0,0 +1,268 @@ +#[diplomat::bridge] +pub mod ffi { + use core::fmt::Write as _; + + use anyhow::Context as _; + use diplomat_runtime::DiplomatWriteable; + + use crate::error::ffi::IronRdpError; + use crate::error::GenericError; + use crate::utils::ffi::VecU8; + + #[diplomat::opaque] + pub struct RDCleanPathPdu(pub ironrdp_rdcleanpath::RDCleanPathPdu); + + impl RDCleanPathPdu { + /// Creates a new RDCleanPath request PDU + /// + /// # Arguments + /// * `x224_pdu` - The X.224 Connection Request PDU bytes + /// * `destination` - The destination RDP server address (e.g., "10.10.0.3:3389") + /// * `proxy_auth` - The JWT authentication token + /// * `pcb` - Optional preconnection blob (for Hyper-V VM connections, empty string if not needed) + pub fn new_request( + x224_pdu: &[u8], + destination: &str, + proxy_auth: &str, + pcb: &str, + ) -> Result, Box> { + let pcb_opt = if pcb.is_empty() { None } else { Some(pcb.to_owned()) }; + + let pdu = ironrdp_rdcleanpath::RDCleanPathPdu::new_request( + x224_pdu.to_vec(), + destination.to_owned(), + proxy_auth.to_owned(), + pcb_opt, + ) + .context("failed to create RDCleanPath request") + .map_err(GenericError)?; + + Ok(Box::new(RDCleanPathPdu(pdu))) + } + + /// Decodes a RDCleanPath PDU from DER-encoded bytes + pub fn from_der(bytes: &[u8]) -> Result, Box> { + let pdu = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(bytes) + .context("failed to decode RDCleanPath PDU") + .map_err(GenericError)?; + + Ok(Box::new(RDCleanPathPdu(pdu))) + } + + /// Encodes the RDCleanPath PDU to DER-encoded bytes + pub fn to_der(&self) -> Result, Box> { + let bytes = self + .0 + .to_der() + .context("failed to encode RDCleanPath PDU") + .map_err(GenericError)?; + + Ok(Box::new(VecU8(bytes))) + } + + /// Detects if the bytes contain a valid RDCleanPath PDU and returns detection result + pub fn detect(bytes: &[u8]) -> Box { + let result = ironrdp_rdcleanpath::RDCleanPathPdu::detect(bytes); + Box::new(RDCleanPathDetectionResult(result)) + } + + /// Gets the type of this RDCleanPath PDU + pub fn get_type(&self) -> Result> { + if self.0.destination.is_some() { + if self.0.proxy_auth.is_none() { + return Err(Self::missing_field("proxy_auth")); + } + + if self.0.x224_connection_pdu.is_none() { + return Err(Self::missing_field("x224_connection_pdu")); + } + + Ok(RDCleanPathResultType::Request) + } else if self.0.server_addr.is_some() { + if self.0.x224_connection_pdu.is_none() { + return Err(Self::missing_field("x224_connection_pdu")); + } + + if self.0.server_cert_chain.is_none() { + return Err(Self::missing_field("server_cert_chain")); + } + + Ok(RDCleanPathResultType::Response) + } else if let Some(error) = &self.0.error { + if error.error_code == ironrdp_rdcleanpath::NEGOTIATION_ERROR_CODE { + if self.0.x224_connection_pdu.is_none() { + return Err(Self::missing_field("x224_connection_pdu")); + } + + Ok(RDCleanPathResultType::NegotiationError) + } else { + Ok(RDCleanPathResultType::GeneralError) + } + } else { + Err(Self::missing_field("error")) + } + } + + /// Gets the X.224 connection response bytes (for Response or NegotiationError variants) + pub fn get_x224_response(&self) -> Result, Box> { + if self.0.server_addr.is_some() { + let x224 = self + .0 + .x224_connection_pdu + .as_ref() + .ok_or_else(|| Self::missing_field("x224_connection_pdu"))?; + self.0 + .server_cert_chain + .as_ref() + .ok_or_else(|| Self::missing_field("server_cert_chain"))?; + + Ok(Box::new(VecU8(x224.as_bytes().to_vec()))) + } else if let Some(error) = &self.0.error { + if error.error_code == ironrdp_rdcleanpath::NEGOTIATION_ERROR_CODE { + let x224 = self + .0 + .x224_connection_pdu + .as_ref() + .ok_or_else(|| Self::missing_field("x224_connection_pdu"))?; + + Ok(Box::new(VecU8(x224.as_bytes().to_vec()))) + } else { + Err(GenericError(anyhow::anyhow!("RDCleanPath variant does not contain X.224 response")).into()) + } + } else { + Err(GenericError(anyhow::anyhow!("RDCleanPath variant does not contain X.224 response")).into()) + } + } + + /// Gets the server certificate chain (for Response variant) + /// Returns a vector iterator of certificate bytes + pub fn get_server_cert_chain(&self) -> Result, Box> { + if self.0.server_addr.is_some() { + self.0 + .x224_connection_pdu + .as_ref() + .ok_or_else(|| Self::missing_field("x224_connection_pdu"))?; + let certs = self + .0 + .server_cert_chain + .as_ref() + .ok_or_else(|| Self::missing_field("server_cert_chain"))?; + + let certs: Vec> = certs.iter().map(|cert| cert.as_bytes().to_vec()).collect(); + Ok(Box::new(CertificateChainIterator { certs, index: 0 })) + } else { + Err(GenericError(anyhow::anyhow!( + "RDCleanPath variant does not contain certificate chain" + )) + .into()) + } + } + + /// Gets the server address string (for Response variant) + pub fn get_server_addr<'a>(&'a self, writeable: &'a mut DiplomatWriteable) { + if self.0.server_addr.is_some() + && self.0.server_cert_chain.is_some() + && self.0.x224_connection_pdu.is_some() + { + if let Some(server_addr) = &self.0.server_addr { + let _ = write!(writeable, "{server_addr}"); + } + } + } + + /// Gets error message (for GeneralError variant) + pub fn get_error_message<'a>(&'a self, writeable: &'a mut DiplomatWriteable) { + if let Ok(err) = self.general_error() { + let _ = write!(writeable, "{err}"); + } + } + + /// Gets the error code (for GeneralError variant) + pub fn get_error_code(&self) -> Result> { + let err = self.general_error()?; + Ok(err.error_code) + } + + /// Gets the HTTP status code if present (for GeneralError variant) + /// Returns error if not present or not a GeneralError variant + pub fn get_http_status_code(&self) -> Result> { + let err = self.general_error()?; + + err.http_status_code + .ok_or_else(|| GenericError(anyhow::anyhow!("HTTP status code not present")).into()) + } + + fn missing_field(field: &'static str) -> Box { + GenericError(anyhow::anyhow!("RDCleanPath is missing {field} field")).into() + } + + fn general_error(&self) -> Result<&ironrdp_rdcleanpath::RDCleanPathErr, Box> { + let error = self.0.error.as_ref().ok_or_else(|| Self::missing_field("error"))?; + + if error.error_code == ironrdp_rdcleanpath::NEGOTIATION_ERROR_CODE { + Err(GenericError(anyhow::anyhow!("not a GeneralError variant")).into()) + } else { + Ok(error) + } + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum RDCleanPathResultType { + Request, + Response, + GeneralError, + NegotiationError, + } + + #[diplomat::opaque] + pub struct RDCleanPathDetectionResult(pub ironrdp_rdcleanpath::DetectionResult); + + impl RDCleanPathDetectionResult { + pub fn is_detected(&self) -> bool { + matches!(self.0, ironrdp_rdcleanpath::DetectionResult::Detected { .. }) + } + + pub fn is_not_enough_bytes(&self) -> bool { + matches!(self.0, ironrdp_rdcleanpath::DetectionResult::NotEnoughBytes) + } + + pub fn is_failed(&self) -> bool { + matches!(self.0, ironrdp_rdcleanpath::DetectionResult::Failed) + } + + pub fn get_total_length(&self) -> Result> { + if let ironrdp_rdcleanpath::DetectionResult::Detected { total_length, .. } = self.0 { + Ok(total_length) + } else { + Err(GenericError(anyhow::anyhow!("detection result is not Detected variant")).into()) + } + } + } + + #[diplomat::opaque] + pub struct CertificateChainIterator { + certs: Vec>, + index: usize, + } + + impl CertificateChainIterator { + pub fn next(&mut self) -> Option> { + if self.index < self.certs.len() { + let cert = self.certs[self.index].clone(); + self.index += 1; + Some(Box::new(VecU8(cert))) + } else { + None + } + } + + pub fn len(&self) -> usize { + self.certs.len() + } + + pub fn is_empty(&self) -> bool { + self.certs.is_empty() + } + } +}