Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental QUIC channel (.NET 9-only) #82

Merged
merged 2 commits into from
Nov 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CoreRemoting.Channels.Quic/CertificateHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

namespace CoreRemoting.Channels.Quic;

internal class CertificateHelper
{
public static X509Certificate2 LoadFromPfx(string pfxFilePath, string pfxPassword) =>
X509CertificateLoader.LoadPkcs12FromFile(pfxFilePath, pfxPassword);

public static X509Certificate2 GenerateSelfSigned(string hostName = "localhost")
{
// generate a new certificate
var now = DateTimeOffset.UtcNow;
SubjectAlternativeNameBuilder sanBuilder = new();
sanBuilder.AddDnsName(hostName);

using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
CertificateRequest req = new($"CN={hostName}", ec, HashAlgorithmName.SHA256);

// Adds purpose
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection
{
new("1.3.6.1.5.5.7.3.1") // serverAuth
},
false));

// Adds usage
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));

// Adds subject alternate names
req.CertificateExtensions.Add(sanBuilder.Build());

// Sign
using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this type

var password = Guid.NewGuid().ToString();
var pfx = crt.Export(X509ContentType.Pfx, password);
var cert = X509CertificateLoader.LoadPkcs12(pfx, password);
return cert;
}
}
33 changes: 33 additions & 0 deletions CoreRemoting.Channels.Quic/CoreRemoting.Channels.Quic.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0.0</TargetFramework>
<RootNamespace>CoreRemoting.Channels.Quic</RootNamespace>
<AssemblyName>CoreRemoting.Channels.Quic</AssemblyName>
<PackageVersion>1.2.1</PackageVersion>
<Authors>Alexey Yakovlev</Authors>
<Description>Quic channels for CoreRemoting</Description>
<Copyright>2024 Alexey Yakovlev</Copyright>
<PackageProjectUrl>https://github.com/theRainbird/CoreRemoting</PackageProjectUrl>
<PackageLicenseUrl></PackageLicenseUrl>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Title>CoreRemoting.Channels.Quic</Title>
<RepositoryUrl>https://github.com/theRainbird/CoreRemoting.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<AssemblyVersion>1.2.1</AssemblyVersion>
<LangVersion>10</LangVersion>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<NoWarn>1701;1702;CA1416</NoWarn>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<NoWarn>1701;1702;1416</NoWarn>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\CoreRemoting\CoreRemoting.csproj" />
</ItemGroup>

</Project>
223 changes: 223 additions & 0 deletions CoreRemoting.Channels.Quic/QuicClientChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Quic;
using System.Net.Security;
using System.Text;
using System.Threading.Tasks;

namespace CoreRemoting.Channels.Quic;

/// <summary>
/// Client side QUIC channel implementation based on System.Net.Quic.
/// </summary>
public class QuicClientChannel : IClientChannel, IRawMessageTransport
{
internal const int MaxMessageSize = 1024 * 1024 * 128;
internal const string ProtocolName = nameof(CoreRemoting);

/// <summary>
/// Gets or sets the URL this channel is connected to.
/// </summary>
public string Url { get; private set; }

private Uri Uri { get; set; }

private IRemotingClient Client { get; set; }

private QuicClientConnectionOptions Options { get; set; }

private QuicConnection Connection { get; set; }

private QuicStream ClientStream { get; set; }

private BinaryReader ClientReader { get; set; }

private BinaryWriter ClientWriter { get; set; }

/// <inheritdoc />
public bool IsConnected { get; private set; }

/// <inheritdoc />
public IRawMessageTransport RawMessageTransport => this;

/// <inheritdoc />
public NetworkException LastException { get; set; }

/// <summary>
/// Event: fires when the channel is connected.
/// </summary>
public event Action Connected;

/// <inheritdoc />
public event Action Disconnected;

/// <inheritdoc />
public event Action<byte[]> ReceiveMessage;

/// <inheritdoc />
public event Action<string, Exception> ErrorOccured;

/// <inheritdoc />
public void Init(IRemotingClient client)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
if (!QuicConnection.IsSupported)
throw new NotSupportedException("QUIC is not supported.");

Url =
"quic://" +
client.Config.ServerHostName + ":" +
Convert.ToString(client.Config.ServerPort) +
"/rpc";

Uri = new Uri(Url);

// prepare QUIC client connection options
Options = new QuicClientConnectionOptions
{
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, Uri.Port), //new DnsEndPoint(Uri.Host, Uri.Port),
DefaultStreamErrorCode = 0x0A,
DefaultCloseErrorCode = 0x0B,
MaxInboundUnidirectionalStreams = 10,
MaxInboundBidirectionalStreams = 100,
ClientAuthenticationOptions = new SslClientAuthenticationOptions()
{
// accept self-signed certificates generated on-the-fly
RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => true,
ApplicationProtocols = new List<SslApplicationProtocol>()
{
new SslApplicationProtocol(ProtocolName)
}
}
};
}

/// <inheritdoc />
public void Connect()
{
ConnectTask = ConnectTask ?? Task.Factory.StartNew(async () =>
{
// connect and open duplex stream
Connection = await QuicConnection.ConnectAsync(Options);
ClientStream = await Connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
ClientReader = new BinaryReader(ClientStream, Encoding.UTF8, leaveOpen: true);
ClientWriter = new BinaryWriter(ClientStream, Encoding.UTF8, leaveOpen: true);

// prepare handshake message
var handshakeMessage = Array.Empty<byte>();
if (Client.MessageEncryption)
{
handshakeMessage = Client.PublicKey;
}

// send handshake message
SendMessage(handshakeMessage);
IsConnected = true;
Connected?.Invoke();

// start listening for incoming messages
_ = Task.Factory.StartNew(() => StartListening());
});

ConnectTask.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
}

private Task ConnectTask { get; set; }

private void StartListening()
{
try
{
while (IsConnected)
{
var messageSize = ClientReader.Read7BitEncodedInt();
var message = ClientReader.ReadBytes(Math.Min(messageSize, MaxMessageSize));
if (message.Length > 0)
{
ReceiveMessage(message);
}
}
}
catch (Exception ex)
{
LastException = ex as NetworkException ??
new NetworkException(ex.Message, ex);

ErrorOccured?.Invoke(ex.Message, ex);
Disconnected?.Invoke();
}
finally
{
Disconnect();
}
}

/// <inheritdoc />
public bool SendMessage(byte[] rawMessage)
{
try
{
if (rawMessage.Length > MaxMessageSize)
throw new InvalidOperationException("Message is too large. Max size: " +
MaxMessageSize + ", actual size: " + rawMessage.Length);

// message length + message body
ClientWriter.Write7BitEncodedInt(rawMessage.Length);
ClientWriter.Write(rawMessage, 0, rawMessage.Length);
return true;
}
catch (Exception ex)
{
LastException = ex as NetworkException ??
new NetworkException(ex.Message, ex);

ErrorOccured?.Invoke(ex.Message, ex);
return false;
}
}

private Task DisconnectTask { get; set; }

/// <inheritdoc />
public void Disconnect()
{
DisconnectTask = DisconnectTask ?? Task.Factory.StartNew(async () =>
{
await Connection.CloseAsync(0x0C);
IsConnected = false;
Disconnected?.Invoke();
});
}

/// <inheritdoc />
public void Dispose()
{
if (Connection == null)
return;

if (IsConnected)
Disconnect();

var task = DisconnectTask;
if (task != null)
task.ConfigureAwait(false)
.GetAwaiter()
.GetResult();

Connection.DisposeAsync()
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
Connection = null;

// clean up readers/writers
ClientReader.Dispose();
ClientReader = null;
ClientWriter.Dispose();
ClientWriter = null;
}
}
Loading
Loading