Ship authenticated remote transports in Unity package
This commit is contained in:
parent
c194bd4cc8
commit
99458dbb57
28 changed files with 309 additions and 53 deletions
|
|
@ -14,7 +14,7 @@
|
|||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Copyright>© Meta Vens and contributors</Copyright>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<Version>1.0.34</Version>
|
||||
<Version>1.0.35</Version>
|
||||
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
|
||||
|
||||
<!-- Symbols/debugging -->
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<EveRoot Condition="'$(EveRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\Eve</EveRoot>
|
||||
<UsePackageArtifacts Condition="'$(UsePackageArtifacts)' == ''">false</UsePackageArtifacts>
|
||||
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.34</CultLibPackageVersion>
|
||||
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.35</CultLibPackageVersion>
|
||||
<EveSurfacePackageVersion Condition="'$(EveSurfacePackageVersion)' == ''">0.3.3</EveSurfacePackageVersion>
|
||||
<RestoreAdditionalProjectSources Condition="'$(PackageFeed)' != ''">$(PackageFeed)</RestoreAdditionalProjectSources>
|
||||
</PropertyGroup>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ $ErrorActionPreference = "Stop"
|
|||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.csproj"
|
||||
$webSocketProjectPath = Join-Path $repoRoot "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj"
|
||||
$quicProjectPath = Join-Path $repoRoot "src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj"
|
||||
$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib"
|
||||
$publishRoot = Join-Path $repoRoot "artifacts\unity-publish"
|
||||
$webSocketPublishRoot = Join-Path $repoRoot "artifacts\unity-websocket-publish"
|
||||
$quicPublishRoot = Join-Path $repoRoot "artifacts\unity-quic-publish"
|
||||
$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native"
|
||||
$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
|
|
@ -27,11 +29,15 @@ if (Test-Path -LiteralPath $publishRoot) {
|
|||
if (Test-Path -LiteralPath $quicPublishRoot) {
|
||||
Remove-Item -LiteralPath $quicPublishRoot -Recurse -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $webSocketPublishRoot) {
|
||||
Remove-Item -LiteralPath $webSocketPublishRoot -Recurse -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $outputRoot) {
|
||||
Remove-Item -LiteralPath $outputRoot -Recurse -Force
|
||||
}
|
||||
|
||||
$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot)
|
||||
$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
|
||||
$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
|
||||
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
|
||||
$publishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
|
||||
}
|
||||
|
|
@ -39,7 +45,15 @@ if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
|
|||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "CultLib publish failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot)
|
||||
$webSocketPublishArguments = @("publish", $webSocketProjectPath, "-c", $Configuration, "-f", "netstandard2.1", "-o", $webSocketPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
|
||||
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
|
||||
$webSocketPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
|
||||
}
|
||||
& dotnet @webSocketPublishArguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "CultLib WebSocket publish failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
|
||||
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
|
||||
$quicPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
|
||||
}
|
||||
|
|
@ -63,6 +77,7 @@ $expectedAssemblies = @(
|
|||
"GameCult.Mesh.dll",
|
||||
"GameCult.Mesh.Quic.Native.dll",
|
||||
"GameCult.Networking.dll",
|
||||
"GameCult.Networking.WebSockets.dll",
|
||||
"Isopoh.Cryptography.Argon2.dll",
|
||||
"Isopoh.Cryptography.Blake2b.dll",
|
||||
"Isopoh.Cryptography.SecureArray.dll",
|
||||
|
|
@ -88,6 +103,9 @@ foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") {
|
|||
foreach ($assembly in Get-ChildItem -LiteralPath $quicPublishRoot -Filter "*.dll") {
|
||||
$publishedByName[$assembly.Name] = $assembly
|
||||
}
|
||||
foreach ($assembly in Get-ChildItem -LiteralPath $webSocketPublishRoot -Filter "*.dll") {
|
||||
$publishedByName[$assembly.Name] = $assembly
|
||||
}
|
||||
foreach ($assemblyName in $expectedAssemblies) {
|
||||
if (-not $publishedByName.ContainsKey($assemblyName)) {
|
||||
throw "CultLib Unity package is missing expected assembly: $assemblyName"
|
||||
|
|
@ -112,6 +130,26 @@ New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party No
|
|||
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") `
|
||||
-Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt")
|
||||
|
||||
$meshPackagePath = Join-Path $pluginRoot "GameCult.Mesh.dll"
|
||||
$meshPackageVersion = [Reflection.AssemblyName]::GetAssemblyName($meshPackagePath).Version.ToString()
|
||||
if ($meshPackageVersion -ne "$unityPackageVersion.0") {
|
||||
throw "Packaged GameCult.Mesh.dll version '$meshPackageVersion' does not match package '$unityPackageVersion'."
|
||||
}
|
||||
$meshPackageMetadata = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($meshPackagePath))
|
||||
foreach ($requiredType in @(
|
||||
"GameCult.Mesh.CultMeshAuthorityTrustPolicy",
|
||||
"GameCult.Mesh.CultMeshRouteCertificate",
|
||||
"GameCult.Mesh.CultMeshHttpsContentTransportConnector")) {
|
||||
if (-not $meshPackageMetadata.Contains($requiredType.Split('.')[-1])) {
|
||||
throw "Packaged GameCult.Mesh.dll is missing required API '$requiredType'."
|
||||
}
|
||||
}
|
||||
$webSocketPackageMetadata = [Text.Encoding]::UTF8.GetString(
|
||||
[IO.File]::ReadAllBytes((Join-Path $pluginRoot "GameCult.Networking.WebSockets.dll")))
|
||||
if (-not $webSocketPackageMetadata.Contains("CultNetWebSocketSchemaClient")) {
|
||||
throw "Packaged WebSocket assembly is missing the Unity-compatible CultNet client."
|
||||
}
|
||||
|
||||
if ($UpdateTemplate) {
|
||||
$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins"
|
||||
foreach ($assemblyName in $expectedAssemblies) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceRevision)) {
|
|||
throw "Could not determine the CultLib source revision for local package identity."
|
||||
}
|
||||
$verificationNonce = [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||
$cultLibLocalVersion = "1.0.34-local.$sourceRevision.$verificationNonce"
|
||||
$cultLibLocalVersion = "1.0.35-local.$sourceRevision.$verificationNonce"
|
||||
$surfaceLocalVersion = "0.3.3-local.$sourceRevision.$verificationNonce"
|
||||
try {
|
||||
& (Join-Path $EveRoot "scripts\pack-dotnet-surface.ps1") `
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ namespace GameCult.Mesh
|
|||
public IReadOnlyList<ICultMeshTransportConnector>? Connectors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets streaming content connectors. TCP content delivery is used by default;
|
||||
/// applications may replace it or explicitly add legacy RUDP.
|
||||
/// Gets or sets streaming content connectors. HTTPS and loopback TCP content
|
||||
/// delivery are used by default; applications may replace them or explicitly add legacy RUDP.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ICultMeshContentTransportConnector>? ContentConnectors { get; set; }
|
||||
|
||||
|
|
@ -184,6 +184,7 @@ namespace GameCult.Mesh
|
|||
},
|
||||
options.ContentConnectors ?? new ICultMeshContentTransportConnector[]
|
||||
{
|
||||
new CultMeshHttpsContentTransportConnector(),
|
||||
new CultMeshTcpContentTransportConnector()
|
||||
},
|
||||
options.RealtimeConnectors,
|
||||
|
|
|
|||
124
src/GameCult.Mesh/CultMeshHttpsContentTransport.cs
Normal file
124
src/GameCult.Mesh/CultMeshHttpsContentTransport.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GameCult.Mesh
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens immutable content over an Odin-certified HTTPS route. Provider
|
||||
/// identity comes from the verified route and TLS endpoint; the transfer
|
||||
/// service remains responsible for checking the advertised chunk hash.
|
||||
/// </summary>
|
||||
public sealed class CultMeshHttpsContentTransportConnector : ICultMeshContentTransportConnector
|
||||
{
|
||||
private readonly Func<HttpMessageHandler> _createHandler;
|
||||
|
||||
public CultMeshHttpsContentTransportConnector(Func<HttpMessageHandler>? createHandler = null)
|
||||
{
|
||||
_createHandler = createHandler ?? (() => new HttpClientHandler());
|
||||
}
|
||||
|
||||
public string ConnectorId => "https-content";
|
||||
public int Priority => 0;
|
||||
|
||||
public bool CanConnect(CultMeshTransportCandidate candidate) =>
|
||||
candidate != null &&
|
||||
Uri.TryCreate(candidate.Endpoint, UriKind.Absolute, out var endpoint) &&
|
||||
string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public Task<ICultMeshContentTransport> ConnectAsync(
|
||||
CultMeshTransportCandidate candidate,
|
||||
CultMeshSessionTarget target,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
|
||||
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||
if (!CanConnect(candidate))
|
||||
throw new NotSupportedException($"HTTPS content connector does not support '{candidate.Endpoint}'.");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult<ICultMeshContentTransport>(new CultMeshHttpsContentTransport(
|
||||
candidate.Endpoint,
|
||||
new HttpClient(_createHandler(), disposeHandler: true),
|
||||
target,
|
||||
candidate.Generation));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CultMeshHttpsContentTransport : ICultMeshContentTransport, ICultMeshVerifiedTransport
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly CultMeshSessionTarget _target;
|
||||
private readonly string _routeGeneration;
|
||||
private bool _disposed;
|
||||
|
||||
public CultMeshHttpsContentTransport(
|
||||
string endpoint,
|
||||
HttpClient client,
|
||||
CultMeshSessionTarget target,
|
||||
string routeGeneration)
|
||||
{
|
||||
Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_target = target ?? throw new ArgumentNullException(nameof(target));
|
||||
_routeGeneration = routeGeneration ?? string.Empty;
|
||||
}
|
||||
|
||||
public string TransportId => "https-content";
|
||||
public string Endpoint { get; }
|
||||
|
||||
public bool IsVerifiedFor(
|
||||
string verseId,
|
||||
string authorityRuntimeId,
|
||||
string protocolId,
|
||||
string routeGeneration) =>
|
||||
string.Equals(_target.VerseId, verseId, StringComparison.Ordinal) &&
|
||||
string.Equals(_target.AuthorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
|
||||
string.Equals(CultMeshProtocols.Content.Value, protocolId, StringComparison.Ordinal) &&
|
||||
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
|
||||
|
||||
public async Task CopyChunkToAsync(
|
||||
CultMeshCdnChunkRef chunk,
|
||||
Stream destination,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshHttpsContentTransport));
|
||||
if (chunk == null) throw new ArgumentNullException(nameof(chunk));
|
||||
if (destination == null) throw new ArgumentNullException(nameof(destination));
|
||||
if (!destination.CanWrite) throw new ArgumentException("Content destination must be writable.", nameof(destination));
|
||||
|
||||
var requestUri = BuildChunkUri(Endpoint, chunk);
|
||||
using var response = await _client.GetAsync(
|
||||
requestUri,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength is long contentLength &&
|
||||
contentLength != chunk.SizeBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"HTTPS content length {contentLength} did not match advertised chunk size {chunk.SizeBytes}.");
|
||||
}
|
||||
using var source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
await source.CopyToAsync(destination, 81920, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private static Uri BuildChunkUri(string endpoint, CultMeshCdnChunkRef chunk)
|
||||
{
|
||||
var builder = new UriBuilder(endpoint);
|
||||
var separator = string.IsNullOrWhiteSpace(builder.Query) ? string.Empty : builder.Query.TrimStart('?') + "&";
|
||||
builder.Query = separator +
|
||||
"chunkHash=" + Uri.EscapeDataString(CultMeshCdn.NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash))) +
|
||||
"&recordKey=" + Uri.EscapeDataString(chunk.RecordKey ?? string.Empty);
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GameCult.Networking.WebSockets;
|
||||
|
||||
internal static class CultNetWebSocketMessageIO
|
||||
{
|
||||
public static Task SendAsync(WebSocket socket, byte[] payload, CancellationToken cancellationToken) =>
|
||||
socket.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Binary, true, cancellationToken);
|
||||
|
||||
public static async Task<byte[]?> ReceiveAsync(
|
||||
WebSocket socket,
|
||||
int maxMessageBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var segment = new byte[Math.Min(16 * 1024, maxMessageBytes)];
|
||||
using var message = new MemoryStream();
|
||||
while (true)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(new ArraySegment<byte>(segment), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close) return null;
|
||||
if (result.MessageType != WebSocketMessageType.Binary)
|
||||
{
|
||||
await socket.CloseAsync(
|
||||
WebSocketCloseStatus.InvalidMessageType,
|
||||
"CultNet requires binary MessagePack WebSocket messages.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
if (message.Length + result.Count > maxMessageBytes)
|
||||
{
|
||||
await socket.CloseAsync(
|
||||
WebSocketCloseStatus.MessageTooBig,
|
||||
"CultNet message exceeds the configured maximum.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
message.Write(segment, 0, result.Count);
|
||||
if (result.EndOfMessage) return message.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
|
|||
/// <inheritdoc />
|
||||
public void Connect(string host, int port)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("Host is required.", nameof(host));
|
||||
if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port));
|
||||
ConnectAsync(new Uri($"ws://{host}:{port}/cultmesh"), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
|
@ -46,7 +46,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
|
|||
/// <summary>Connects to an exact WebSocket route.</summary>
|
||||
public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
|
||||
if (endpoint.Scheme is not ("ws" or "wss"))
|
||||
throw new ArgumentException("CultNet WebSocket endpoint must use ws:// or wss://.", nameof(endpoint));
|
||||
ThrowIfDisposed();
|
||||
|
|
@ -69,7 +69,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
|
|||
/// <inheritdoc />
|
||||
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
if (message == null) throw new ArgumentNullException(nameof(message));
|
||||
ThrowIfDisposed();
|
||||
var socket = _socket;
|
||||
if (socket?.State != WebSocketState.Open)
|
||||
|
|
@ -92,7 +92,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
|
|||
/// <inheritdoc />
|
||||
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (callback == null) throw new ArgumentNullException(nameof(callback));
|
||||
ThrowIfDisposed();
|
||||
lock (_handlerGate)
|
||||
{
|
||||
|
|
@ -161,6 +161,6 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
|
|||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(CultNetWebSocketSchemaClient));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,44 +178,6 @@ public sealed class CultNetWebSocketSchemaServer :
|
|||
}
|
||||
}
|
||||
|
||||
internal static class CultNetWebSocketMessageIO
|
||||
{
|
||||
public static Task SendAsync(WebSocket socket, byte[] payload, CancellationToken cancellationToken) =>
|
||||
socket.SendAsync(payload, WebSocketMessageType.Binary, true, cancellationToken);
|
||||
|
||||
public static async Task<byte[]?> ReceiveAsync(
|
||||
WebSocket socket,
|
||||
int maxMessageBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var segment = new byte[Math.Min(16 * 1024, maxMessageBytes)];
|
||||
using var message = new MemoryStream();
|
||||
while (true)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(segment, cancellationToken).ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close) return null;
|
||||
if (result.MessageType != WebSocketMessageType.Binary)
|
||||
{
|
||||
await socket.CloseAsync(
|
||||
WebSocketCloseStatus.InvalidMessageType,
|
||||
"CultNet requires binary MessagePack WebSocket messages.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
if (message.Length + result.Count > maxMessageBytes)
|
||||
{
|
||||
await socket.CloseAsync(
|
||||
WebSocketCloseStatus.MessageTooBig,
|
||||
"CultNet message exceeds the configured maximum.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
message.Write(segment, 0, result.Count);
|
||||
if (result.EndOfMessage) return message.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ASP.NET Core endpoint mapping for the CultNet WebSocket schema server.</summary>
|
||||
public static class CultNetWebSocketEndpointRouteBuilderExtensions
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0;netstandard2.1</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Description>CultNet schema-v0 WebSocket host adapter for browser and web runtimes</Description>
|
||||
<PackageId>GameCult.Networking.WebSockets</PackageId>
|
||||
<PackageTags>game;networking;websocket;cultnet;cultmesh</PackageTags>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.1'">
|
||||
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" />
|
||||
<Compile Remove="CultNetWebSocketSchemaServer.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
|
|
@ -173,6 +177,54 @@ public sealed class CultMeshAuthorityProofTests
|
|||
connector.Attempts.Should().Equal(valid.Endpoint);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AuthenticatedRemoteContentUsesCertifiedHttpsAndStreamsTheAdvertisedChunk()
|
||||
{
|
||||
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var payload = Encoding.UTF8.GetBytes("certified-content");
|
||||
var route = CultMeshAuthorityProof.CreateSignedRoute(
|
||||
"aetheria", "aetheria-daemon", "https://provider.example/cultmesh/content",
|
||||
new[] { CultMeshProtocols.Content.Value }, 0, "content-generation",
|
||||
CultMeshEcdsaP256PublicKey.From("provider-content", provider), "odin-content",
|
||||
Now.AddMinutes(-1), Now.AddMinutes(5), odin);
|
||||
var descriptor = new CultMeshVerseDescriptor(
|
||||
"aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster,
|
||||
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
||||
authorityRoutes: new[] { route });
|
||||
using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) });
|
||||
var handler = new FixedContentHandler(payload);
|
||||
using var manager = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
new ICultMeshContentTransportConnector[]
|
||||
{
|
||||
new CultMeshHttpsContentTransportConnector(() => handler)
|
||||
},
|
||||
new CultMeshSessionManagerOptions
|
||||
{
|
||||
Trust = new CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote,
|
||||
new[] { CultMeshEcdsaP256PublicKey.From("odin-content", odin) }),
|
||||
Clock = new FixedClock(Now)
|
||||
});
|
||||
|
||||
var session = await manager.ConnectContentAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria-daemon"));
|
||||
using var destination = new MemoryStream();
|
||||
await session.CopyChunkToAsync(new CultMeshCdnChunkRef
|
||||
{
|
||||
ChunkHash = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(),
|
||||
RecordKey = "cdn:chunk:certified",
|
||||
SizeBytes = payload.Length
|
||||
}, destination);
|
||||
|
||||
destination.ToArray().Should().Equal(payload);
|
||||
handler.RequestUri.Should().NotBeNull();
|
||||
handler.RequestUri!.Scheme.Should().Be(Uri.UriSchemeHttps);
|
||||
handler.RequestUri.Query.Should().Contain("chunkHash=").And.Contain("recordKey=");
|
||||
}
|
||||
|
||||
private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) =>
|
||||
CultMeshAuthorityProof.CreateSignedRoute(
|
||||
"aetheria",
|
||||
|
|
@ -326,4 +378,30 @@ public sealed class CultMeshAuthorityProofTests
|
|||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class FixedContentHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly byte[] _payload;
|
||||
public FixedContentHandler(byte[] payload) => _payload = payload;
|
||||
public Uri? RequestUri { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestUri = request.RequestUri;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(_payload)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedClock : ICultMeshClock
|
||||
{
|
||||
public FixedClock(DateTimeOffset now) => UtcNow = now;
|
||||
public DateTimeOffset UtcNow { get; }
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) =>
|
||||
Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"GameCult.Mesh.dll",
|
||||
"GameCult.Mesh.Quic.Native.dll",
|
||||
"GameCult.Networking.dll",
|
||||
"GameCult.Networking.WebSockets.dll",
|
||||
"Isopoh.Cryptography.Argon2.dll",
|
||||
"Isopoh.Cryptography.Blake2b.dll",
|
||||
"Isopoh.Cryptography.SecureArray.dll",
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 78372006a7d6494ca38f58a78eb535a8
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "org.gamecult.cultlib",
|
||||
"displayName": "CultLib",
|
||||
"version": "1.0.48",
|
||||
"version": "1.0.49",
|
||||
"unity": "2021.3",
|
||||
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
Loading…
Reference in a new issue