feat(server): harden hostile input and overload behavior (#15)
quality-gate / quality (push) Failing after 1m5s
quality-gate / quality (push) Failing after 1m5s
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||
|
||||
internal sealed class AbuseProtectionOptions
|
||||
{
|
||||
public const string SectionName = "Rendezvous:AbuseProtection";
|
||||
|
||||
[Range(1, 60)]
|
||||
public int WindowSeconds { get; set; } = 1;
|
||||
|
||||
[Range(1_000, 1_000_000)]
|
||||
public int MaxTrackedKeys { get; set; } = 100_000;
|
||||
|
||||
[Range(0, 100_000)]
|
||||
public int CriticalTrackedKeyReserve { get; set; } = 2_048;
|
||||
|
||||
[Range(1_000, 999_999)]
|
||||
public int UdpTrackedKeyLimit { get; set; } = 70_000;
|
||||
|
||||
public string[] TrustedProxyAddresses { get; set; } = [];
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HealthGlobalRequestsPerWindow { get; set; } = 1_000;
|
||||
|
||||
[Range(1, 10_000)]
|
||||
public int HealthGlobalConcurrency { get; set; } = 32;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HealthIpPrefixRequestsPerWindow { get; set; } = 120;
|
||||
|
||||
[Range(1, 1_000)]
|
||||
public int HealthIpPrefixConcurrency { get; set; } = 8;
|
||||
|
||||
[Range(1, 1_000_000)]
|
||||
public int HttpGlobalRequestsPerWindow { get; set; } = 20_000;
|
||||
|
||||
[Range(1, 1_000_000)]
|
||||
public int HttpOptionalRequestsPerWindow { get; set; } = 18_000;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpIpPrefixRequestsPerWindow { get; set; } = 500;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpOptionalIpPrefixRequestsPerWindow { get; set; } = 450;
|
||||
|
||||
[Range(1, 1_000_000)]
|
||||
public int HttpOperationRequestsPerWindow { get; set; } = 5_000;
|
||||
|
||||
[Range(1, 1_000_000)]
|
||||
public int HttpTenantRequestsPerWindow { get; set; } = 2_000;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpPrincipalRequestsPerWindow { get; set; } = 500;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpResourceRequestsPerWindow { get; set; } = 200;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpGlobalConcurrency { get; set; } = 1_024;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpOptionalConcurrency { get; set; } = 768;
|
||||
|
||||
[Range(1, 10_000)]
|
||||
public int HttpIpPrefixConcurrency { get; set; } = 64;
|
||||
|
||||
[Range(1, 10_000)]
|
||||
public int HttpOptionalIpPrefixConcurrency { get; set; } = 48;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpOperationConcurrency { get; set; } = 256;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int HttpTenantConcurrency { get; set; } = 256;
|
||||
|
||||
[Range(1, 10_000)]
|
||||
public int HttpPrincipalConcurrency { get; set; } = 32;
|
||||
|
||||
[Range(1, 10_000)]
|
||||
public int HttpResourceConcurrency { get; set; } = 16;
|
||||
|
||||
[Range(1, 10_000_000)]
|
||||
public int UdpGlobalDatagramsPerWindow { get; set; } = 100_000;
|
||||
|
||||
[Range(1, 1_000_000)]
|
||||
public int UdpIpPrefixDatagramsPerWindow { get; set; } = 2_000;
|
||||
|
||||
[Range(1, 10_000_000)]
|
||||
public int UdpOperationDatagramsPerWindow { get; set; } = 50_000;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int UdpCapabilityDatagramsPerWindow { get; set; } = 120;
|
||||
|
||||
[Range(1, 100_000)]
|
||||
public int UdpResourceDatagramsPerWindow { get; set; } = 240;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||
|
||||
internal sealed class AbuseProtectionService
|
||||
{
|
||||
private readonly AbuseProtectionOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TrackerState _httpTracker;
|
||||
private readonly TrackerState _udpTracker;
|
||||
|
||||
public AbuseProtectionService(
|
||||
IOptions<AbuseProtectionOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
_httpTracker = new(now);
|
||||
_udpTracker = new(now);
|
||||
}
|
||||
|
||||
public bool TryAcquireHttpIngress(
|
||||
IPAddress? remoteAddress,
|
||||
string operation,
|
||||
out AbuseLease? lease,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
string prefix = GetNetworkPrefix(remoteAddress);
|
||||
List<RateDimension> rates =
|
||||
[
|
||||
new("http:rate:global", _options.HttpGlobalRequestsPerWindow),
|
||||
new($"http:rate:ip:{prefix}", _options.HttpIpPrefixRequestsPerWindow),
|
||||
new($"http:rate:operation:{operation}", _options.HttpOperationRequestsPerWindow),
|
||||
];
|
||||
List<RateDimension> concurrency =
|
||||
[
|
||||
new("http:concurrency:global", _options.HttpGlobalConcurrency),
|
||||
new($"http:concurrency:ip:{prefix}", _options.HttpIpPrefixConcurrency),
|
||||
new($"http:concurrency:operation:{operation}", _options.HttpOperationConcurrency),
|
||||
];
|
||||
if (!IsLeaseCriticalOperation(operation))
|
||||
{
|
||||
rates.Add(new("http:rate:optional", _options.HttpOptionalRequestsPerWindow));
|
||||
rates.Add(new($"http:rate:optional-ip:{prefix}",
|
||||
_options.HttpOptionalIpPrefixRequestsPerWindow));
|
||||
concurrency.Add(new("http:concurrency:optional", _options.HttpOptionalConcurrency));
|
||||
concurrency.Add(new($"http:concurrency:optional-ip:{prefix}",
|
||||
_options.HttpOptionalIpPrefixConcurrency));
|
||||
}
|
||||
|
||||
return TryAcquire(
|
||||
[.. rates],
|
||||
[.. concurrency],
|
||||
TrackerDomain.Http,
|
||||
IsLeaseCriticalOperation(operation),
|
||||
out lease,
|
||||
out retryAfterSeconds);
|
||||
}
|
||||
|
||||
public bool TryAcquireHealthIngress(
|
||||
IPAddress? remoteAddress,
|
||||
out AbuseLease? lease,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
string prefix = GetNetworkPrefix(remoteAddress);
|
||||
RateDimension[] rates =
|
||||
[
|
||||
new("health:rate:global", _options.HealthGlobalRequestsPerWindow),
|
||||
new($"health:rate:ip:{prefix}", _options.HealthIpPrefixRequestsPerWindow),
|
||||
];
|
||||
RateDimension[] concurrency =
|
||||
[
|
||||
new("health:concurrency:global", _options.HealthGlobalConcurrency),
|
||||
new($"health:concurrency:ip:{prefix}", _options.HealthIpPrefixConcurrency),
|
||||
];
|
||||
return TryAcquire(
|
||||
rates,
|
||||
concurrency,
|
||||
TrackerDomain.Http,
|
||||
true,
|
||||
out lease,
|
||||
out retryAfterSeconds);
|
||||
}
|
||||
|
||||
public bool TryAcquireHttpIdentity(
|
||||
string operation,
|
||||
string? tenant,
|
||||
string? principal,
|
||||
string? resource,
|
||||
out AbuseLease? lease,
|
||||
out int retryAfterSeconds) => TryAcquireHttpIdentity(
|
||||
operation,
|
||||
null,
|
||||
tenant,
|
||||
principal,
|
||||
resource,
|
||||
out lease,
|
||||
out retryAfterSeconds);
|
||||
|
||||
public bool TryAcquireHttpIdentity(
|
||||
string operation,
|
||||
IPAddress? remoteAddress,
|
||||
string? tenant,
|
||||
string? principal,
|
||||
string? resource,
|
||||
out AbuseLease? lease,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
string sourcePrefix = GetNetworkPrefix(remoteAddress);
|
||||
List<RateDimension> rates = [];
|
||||
List<RateDimension> concurrency = [];
|
||||
AddDimension(rates, concurrency, "tenant", tenant,
|
||||
_options.HttpTenantRequestsPerWindow, _options.HttpTenantConcurrency);
|
||||
AddDimension(rates, concurrency, "principal", principal,
|
||||
_options.HttpPrincipalRequestsPerWindow, _options.HttpPrincipalConcurrency);
|
||||
AddDimension(rates, concurrency, "resource", resource,
|
||||
_options.HttpResourceRequestsPerWindow, _options.HttpResourceConcurrency);
|
||||
return TryAcquire(
|
||||
[.. rates],
|
||||
[.. concurrency],
|
||||
TrackerDomain.Http,
|
||||
IsLeaseCriticalOperation(operation),
|
||||
out lease,
|
||||
out retryAfterSeconds);
|
||||
|
||||
void AddDimension(
|
||||
List<RateDimension> rateDimensions,
|
||||
List<RateDimension> concurrencyDimensions,
|
||||
string kind,
|
||||
string? value,
|
||||
int rateLimit,
|
||||
int concurrencyLimit)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind == "resource")
|
||||
{
|
||||
string validationKey =
|
||||
$"http:source-resource:{operation}:{sourcePrefix}:{value}";
|
||||
rateDimensions.Add(new($"{validationKey}:rate", rateLimit));
|
||||
concurrencyDimensions.Add(new($"{validationKey}:concurrency", concurrencyLimit));
|
||||
}
|
||||
|
||||
string key = kind == "resource"
|
||||
? $"http:resource-scoped:{operation}:{tenant ?? string.Empty}|{principal ?? string.Empty}:{value}"
|
||||
: $"http:{kind}:{operation}:{value}";
|
||||
rateDimensions.Add(new($"{key}:rate", rateLimit));
|
||||
concurrencyDimensions.Add(new($"{key}:concurrency", concurrencyLimit));
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryAcceptUdpIngress(IPAddress? remoteAddress, string operation)
|
||||
{
|
||||
string prefix = GetNetworkPrefix(remoteAddress);
|
||||
RateDimension[] rates =
|
||||
[
|
||||
new("udp:rate:global", _options.UdpGlobalDatagramsPerWindow),
|
||||
new($"udp:rate:ip:{prefix}", _options.UdpIpPrefixDatagramsPerWindow),
|
||||
new($"udp:rate:operation:{operation}", _options.UdpOperationDatagramsPerWindow),
|
||||
];
|
||||
return TryAcquire(
|
||||
rates,
|
||||
[],
|
||||
TrackerDomain.Udp,
|
||||
false,
|
||||
out AbuseLease? lease,
|
||||
out _)
|
||||
&& DisposeAccepted(lease);
|
||||
}
|
||||
|
||||
public bool TryAcceptUdpIdentity(
|
||||
string operation,
|
||||
string capability,
|
||||
string resource) => TryAcceptUdpIdentity(
|
||||
operation,
|
||||
null,
|
||||
capability,
|
||||
resource);
|
||||
|
||||
public bool TryAcceptUdpIdentity(
|
||||
string operation,
|
||||
IPAddress? remoteAddress,
|
||||
string capability,
|
||||
string resource)
|
||||
{
|
||||
string sourcePrefix = GetNetworkPrefix(remoteAddress);
|
||||
string capabilityFingerprint = FingerprintSecret(capability);
|
||||
RateDimension[] rates =
|
||||
[
|
||||
new($"udp:rate:capability:{operation}:{capabilityFingerprint}",
|
||||
_options.UdpCapabilityDatagramsPerWindow),
|
||||
new($"udp:rate:source-resource:{operation}:{sourcePrefix}:{resource}",
|
||||
_options.UdpResourceDatagramsPerWindow),
|
||||
new($"udp:rate:resource:{operation}:{capabilityFingerprint}:{resource}",
|
||||
_options.UdpResourceDatagramsPerWindow),
|
||||
];
|
||||
return TryAcquire(
|
||||
rates,
|
||||
[],
|
||||
TrackerDomain.Udp,
|
||||
false,
|
||||
out AbuseLease? lease,
|
||||
out _)
|
||||
&& DisposeAccepted(lease);
|
||||
}
|
||||
|
||||
public static string FingerprintSecret(string secret)
|
||||
{
|
||||
int byteCount = Encoding.UTF8.GetByteCount(secret);
|
||||
byte[]? rented = null;
|
||||
Span<byte> encoded = byteCount <= 1_024
|
||||
? stackalloc byte[byteCount]
|
||||
: (rented = ArrayPool<byte>.Shared.Rent(byteCount)).AsSpan(0, byteCount);
|
||||
Span<byte> digest = stackalloc byte[32];
|
||||
try
|
||||
{
|
||||
_ = Encoding.UTF8.GetBytes(secret, encoded);
|
||||
_ = SHA256.HashData(encoded, digest);
|
||||
return Convert.ToHexString(digest[..12]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
if (rented is not null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(rented);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal int TrackedKeyCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int http;
|
||||
int udp;
|
||||
lock (_httpTracker.Gate)
|
||||
{
|
||||
http = _httpTracker.WindowCounts.Count + _httpTracker.ConcurrencyCounts.Count;
|
||||
}
|
||||
|
||||
lock (_udpTracker.Gate)
|
||||
{
|
||||
udp = _udpTracker.WindowCounts.Count + _udpTracker.ConcurrencyCounts.Count;
|
||||
}
|
||||
|
||||
return http + udp;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryAcquire(
|
||||
ReadOnlySpan<RateDimension> rates,
|
||||
ReadOnlySpan<RateDimension> concurrency,
|
||||
TrackerDomain domain,
|
||||
bool canUseCriticalReserve,
|
||||
out AbuseLease? lease,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
TrackerState tracker = domain == TrackerDomain.Udp ? _udpTracker : _httpTracker;
|
||||
lock (tracker.Gate)
|
||||
{
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
TimeSpan window = TimeSpan.FromSeconds(_options.WindowSeconds);
|
||||
if (now - tracker.WindowStartedAt >= window || now < tracker.WindowStartedAt)
|
||||
{
|
||||
tracker.WindowCounts.Clear();
|
||||
tracker.WindowStartedAt = now;
|
||||
}
|
||||
|
||||
retryAfterSeconds = Math.Max(
|
||||
1,
|
||||
(int)Math.Ceiling((window - (now - tracker.WindowStartedAt)).TotalSeconds));
|
||||
int stagedNewKeys = 0;
|
||||
int partitionLimit = domain == TrackerDomain.Udp
|
||||
? _options.UdpTrackedKeyLimit
|
||||
: _options.MaxTrackedKeys - _options.UdpTrackedKeyLimit;
|
||||
int maxTrackedKeys = domain == TrackerDomain.Udp || canUseCriticalReserve
|
||||
? partitionLimit
|
||||
: partitionLimit - _options.CriticalTrackedKeyReserve;
|
||||
if (!CanAcquireAll(
|
||||
tracker,
|
||||
tracker.WindowCounts,
|
||||
rates,
|
||||
maxTrackedKeys,
|
||||
ref stagedNewKeys)
|
||||
|| !CanAcquireAll(
|
||||
tracker,
|
||||
tracker.ConcurrencyCounts,
|
||||
concurrency,
|
||||
maxTrackedKeys,
|
||||
ref stagedNewKeys))
|
||||
{
|
||||
lease = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (RateDimension dimension in rates)
|
||||
{
|
||||
tracker.WindowCounts[dimension.Key] =
|
||||
tracker.WindowCounts.GetValueOrDefault(dimension.Key) + 1;
|
||||
}
|
||||
|
||||
if (concurrency.IsEmpty)
|
||||
{
|
||||
lease = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
string[] acquiredConcurrency = new string[concurrency.Length];
|
||||
for (int index = 0; index < concurrency.Length; index++)
|
||||
{
|
||||
RateDimension dimension = concurrency[index];
|
||||
tracker.ConcurrencyCounts[dimension.Key] =
|
||||
tracker.ConcurrencyCounts.GetValueOrDefault(dimension.Key) + 1;
|
||||
acquiredConcurrency[index] = dimension.Key;
|
||||
}
|
||||
|
||||
lease = new AbuseLease(this, tracker, acquiredConcurrency);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanAcquireAll(
|
||||
TrackerState tracker,
|
||||
Dictionary<string, int> counts,
|
||||
ReadOnlySpan<RateDimension> dimensions,
|
||||
int maxTrackedKeys,
|
||||
ref int stagedNewKeys)
|
||||
{
|
||||
foreach (RateDimension dimension in dimensions)
|
||||
{
|
||||
if (counts.TryGetValue(dimension.Key, out int current))
|
||||
{
|
||||
if (current >= dimension.Limit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
stagedNewKeys++;
|
||||
if (tracker.WindowCounts.Count + tracker.ConcurrencyCounts.Count + stagedNewKeys
|
||||
> maxTrackedKeys)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Release(TrackerState tracker, string[] keys)
|
||||
{
|
||||
lock (tracker.Gate)
|
||||
{
|
||||
foreach (string key in keys)
|
||||
{
|
||||
if (!tracker.ConcurrencyCounts.TryGetValue(key, out int current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current <= 1)
|
||||
{
|
||||
tracker.ConcurrencyCounts.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
tracker.ConcurrencyCounts[key] = current - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DisposeAccepted(AbuseLease? lease)
|
||||
{
|
||||
lease?.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsLeaseCriticalOperation(string operation) => operation is
|
||||
"RenewSessionLease" or "UpdateSession" or "DeleteSession";
|
||||
|
||||
private static string GetNetworkPrefix(IPAddress? address)
|
||||
{
|
||||
if (address is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||
byte[] bytes = normalized.GetAddressBytes();
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
bytes[3] = 0;
|
||||
return $"4:{Convert.ToHexString(bytes)}:24";
|
||||
}
|
||||
|
||||
if (bytes.Length == 16)
|
||||
{
|
||||
Array.Clear(bytes, 7, 9);
|
||||
return $"6:{Convert.ToHexString(bytes)}:56";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private readonly record struct RateDimension(string Key, int Limit);
|
||||
|
||||
private enum TrackerDomain
|
||||
{
|
||||
Http,
|
||||
Udp,
|
||||
}
|
||||
|
||||
internal sealed class TrackerState(DateTimeOffset windowStartedAt)
|
||||
{
|
||||
public object Gate { get; } = new();
|
||||
public Dictionary<string, int> WindowCounts { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, int> ConcurrencyCounts { get; } = new(StringComparer.Ordinal);
|
||||
public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt;
|
||||
}
|
||||
|
||||
internal sealed class AbuseLease : IDisposable
|
||||
{
|
||||
private AbuseProtectionService? _owner;
|
||||
private readonly TrackerState _tracker;
|
||||
private readonly string[] _keys;
|
||||
|
||||
internal AbuseLease(
|
||||
AbuseProtectionService owner,
|
||||
TrackerState tracker,
|
||||
string[] keys)
|
||||
{
|
||||
_owner = owner;
|
||||
_tracker = tracker;
|
||||
_keys = keys;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _owner, null) is not null)
|
||||
{
|
||||
Release(_tracker, _keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||
|
||||
internal sealed class HttpAbuseProtectionMiddleware(
|
||||
RequestDelegate next,
|
||||
AbuseProtectionService protection)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
IHttpMaxRequestBodySizeFeature? bodySize =
|
||||
context.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||
if (bodySize is { IsReadOnly: false })
|
||||
{
|
||||
bodySize.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes;
|
||||
}
|
||||
|
||||
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
|
||||
?.EndpointName ?? "Unmatched";
|
||||
bool healthEndpoint = operation is "GetLiveness" or "GetReadiness";
|
||||
bool acquired = healthEndpoint
|
||||
? protection.TryAcquireHealthIngress(
|
||||
context.Connection.RemoteIpAddress,
|
||||
out AbuseProtectionService.AbuseLease? lease,
|
||||
out int retryAfterSeconds)
|
||||
: protection.TryAcquireHttpIngress(
|
||||
context.Connection.RemoteIpAddress,
|
||||
operation,
|
||||
out lease,
|
||||
out retryAfterSeconds);
|
||||
if (!acquired)
|
||||
{
|
||||
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
await WriteErrorAsync(
|
||||
context,
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
RendezvousErrorCode.RateLimited,
|
||||
"The request rate limit was exceeded.",
|
||||
retryAfterSeconds).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using (lease)
|
||||
{
|
||||
if (context.Request.ContentLength > ContractLimits.HttpRequestMaxBytes)
|
||||
{
|
||||
await WriteErrorAsync(
|
||||
context,
|
||||
StatusCodes.Status413PayloadTooLarge,
|
||||
RendezvousErrorCode.InvalidRequest,
|
||||
"The request body exceeds the supported size.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task WriteErrorAsync(
|
||||
HttpContext context,
|
||||
int status,
|
||||
RendezvousErrorCode code,
|
||||
string message,
|
||||
int? retryAfterSeconds = null)
|
||||
{
|
||||
context.Response.StatusCode = status;
|
||||
return context.Response.WriteAsJsonAsync(
|
||||
new ApiError
|
||||
{
|
||||
Code = code,
|
||||
Message = message,
|
||||
RetryAfterSeconds = retryAfterSeconds,
|
||||
},
|
||||
ContractJson.Options,
|
||||
contentType: "application/json",
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||
|
||||
internal static class TrustedProxyForwarding
|
||||
{
|
||||
public static bool IsEnabled(AbuseProtectionOptions options) =>
|
||||
options.TrustedProxyAddresses is { Length: > 0 };
|
||||
|
||||
public static void Configure(
|
||||
ForwardedHeadersOptions forwarded,
|
||||
AbuseProtectionOptions abuse)
|
||||
{
|
||||
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
|
||||
forwarded.ForwardLimit = 1;
|
||||
forwarded.KnownProxies.Clear();
|
||||
forwarded.KnownIPNetworks.Clear();
|
||||
foreach (string address in abuse.TrustedProxyAddresses ?? [])
|
||||
{
|
||||
forwarded.KnownProxies.Add(IPAddress.Parse(address));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
@@ -20,6 +21,7 @@ internal static class ContractEndpoints
|
||||
.Accepts<RegisterSessionRequest>("application/json")
|
||||
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
@@ -31,45 +33,54 @@ internal static class ContractEndpoints
|
||||
.Accepts<RenewLeaseRequest>("application/json")
|
||||
.Produces<RenewLeaseResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("RenewSessionLease");
|
||||
sessions.MapPut("/{listingId}", UpdateSession)
|
||||
.Accepts<UpdateSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("UpdateSession");
|
||||
sessions.MapDelete("/{listingId}", DeleteSession)
|
||||
.Accepts<DeleteSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("DeleteSession");
|
||||
sessions.MapGet("/", BrowseSessions)
|
||||
.Produces<BrowseSessionsResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseSessions");
|
||||
sessions.MapGet("/{listingId}", GetSession)
|
||||
.Produces<GetSessionResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("GetSession");
|
||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseHostJoinAttempts");
|
||||
|
||||
@@ -80,6 +91,7 @@ internal static class ContractEndpoints
|
||||
.Accepts<CreateJoinAttemptRequest>("application/json")
|
||||
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
@@ -90,14 +102,17 @@ internal static class ContractEndpoints
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("CancelJoinAttempt");
|
||||
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
||||
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
||||
.Produces<ReportConnectionOutcomeResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("ReportConnectionOutcome");
|
||||
|
||||
@@ -109,6 +124,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -122,13 +138,29 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
|
||||
principal!,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||
: Error(result.Error);
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"RegisterSession",
|
||||
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||
publisher.Subject,
|
||||
null,
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
|
||||
principal!,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult RenewLease(
|
||||
@@ -137,6 +169,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -150,14 +183,30 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"RenewSessionLease",
|
||||
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||
publisher.Subject,
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult UpdateSession(
|
||||
@@ -166,6 +215,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -179,12 +229,28 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Update(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"UpdateSession",
|
||||
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||
publisher.Subject,
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
SessionServiceResult<bool> result = sessions.Update(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DeleteSession(
|
||||
@@ -193,6 +259,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -206,12 +273,28 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Delete(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"DeleteSession",
|
||||
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||
publisher.Subject,
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
SessionServiceResult<bool> result = sessions.Delete(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult BrowseSessions(
|
||||
@@ -224,6 +307,8 @@ internal static class ContractEndpoints
|
||||
[FromQuery] bool? excludeFull,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
@@ -233,20 +318,35 @@ internal static class ContractEndpoints
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"BrowseSessions",
|
||||
Tenant(parsedGameId, parsedEnvironmentId),
|
||||
null,
|
||||
null,
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
Cursor = cursor,
|
||||
}, cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
Cursor = cursor,
|
||||
}, cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetSession(
|
||||
@@ -256,6 +356,8 @@ internal static class ContractEndpoints
|
||||
[FromQuery] string environmentId,
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
|
||||
@@ -269,15 +371,30 @@ internal static class ContractEndpoints
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||
listingId,
|
||||
parsedGameId,
|
||||
parsedEnvironmentId,
|
||||
protocolVersion,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"GetSession",
|
||||
Tenant(parsedGameId, parsedEnvironmentId),
|
||||
null,
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||
listingId,
|
||||
parsedGameId,
|
||||
parsedEnvironmentId,
|
||||
protocolVersion,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult BrowseHostJoinAttempts(
|
||||
@@ -287,23 +404,41 @@ internal static class ContractEndpoints
|
||||
[FromQuery] int? pageSize,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||
listingId,
|
||||
contractVersion,
|
||||
leaseToken,
|
||||
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
cursor,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"BrowseHostJoinAttempts",
|
||||
null,
|
||||
AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty),
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||
listingId,
|
||||
contractVersion,
|
||||
leaseToken,
|
||||
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
cursor,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CreateJoinAttempt(
|
||||
[FromBody] CreateJoinAttemptRequest request,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -313,26 +448,58 @@ internal static class ContractEndpoints
|
||||
}
|
||||
|
||||
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||
clientSubject,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||
: Error(result.Error);
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"CreateJoinAttempt",
|
||||
Tenant(request.GameId, request.EnvironmentId),
|
||||
clientSubject,
|
||||
request.ListingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||
clientSubject,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CancelJoinAttempt(
|
||||
JoinAttemptId attemptId,
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"CancelJoinAttempt",
|
||||
null,
|
||||
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
|
||||
attemptId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ReportConnectionOutcome(
|
||||
@@ -340,16 +507,33 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromBody] ReportConnectionOutcomeRequest request,
|
||||
[FromServices] ConnectionOutcomeService outcomes,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ConnectionOutcomeServiceResult result = outcomes.Report(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"ReportConnectionOutcome",
|
||||
null,
|
||||
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
|
||||
attemptId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
ConnectionOutcomeServiceResult result = outcomes.Report(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryAuthenticatePublisher(
|
||||
@@ -377,11 +561,41 @@ internal static class ContractEndpoints
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Error(RendezvousErrorCode code) => Results.Json(
|
||||
private static bool TryAcquireIdentity(
|
||||
AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
string operation,
|
||||
string? tenant,
|
||||
string? principal,
|
||||
string? resource,
|
||||
out AbuseProtectionService.AbuseLease? lease)
|
||||
{
|
||||
if (abuseProtection.TryAcquireHttpIdentity(
|
||||
operation,
|
||||
httpContext.Connection.RemoteIpAddress,
|
||||
tenant,
|
||||
principal,
|
||||
resource,
|
||||
out lease,
|
||||
out int retryAfterSeconds))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Tenant(GameId gameId, EnvironmentId environmentId) =>
|
||||
$"{gameId.Value}/{environmentId.Value}";
|
||||
|
||||
private static IResult Error(RendezvousErrorCode code, int? retryAfterSeconds = null) => Results.Json(
|
||||
new ApiError
|
||||
{
|
||||
Code = code,
|
||||
Message = ErrorMessage(code),
|
||||
RetryAfterSeconds = retryAfterSeconds,
|
||||
},
|
||||
ContractJson.Options,
|
||||
statusCode: ErrorStatus(code));
|
||||
@@ -392,6 +606,18 @@ internal static class ContractEndpoints
|
||||
return Error(RendezvousErrorCode.AuthenticationRequired);
|
||||
}
|
||||
|
||||
private static IResult RateLimited(HttpContext context)
|
||||
{
|
||||
int? retryAfterSeconds = int.TryParse(
|
||||
context.Response.Headers.RetryAfter,
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int parsed)
|
||||
? Math.Clamp(parsed, 1, 60)
|
||||
: null;
|
||||
return Error(RendezvousErrorCode.RateLimited, retryAfterSeconds);
|
||||
}
|
||||
|
||||
private static int ErrorStatus(RendezvousErrorCode code) => code switch
|
||||
{
|
||||
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||
@@ -417,6 +643,7 @@ internal static class ContractEndpoints
|
||||
RendezvousErrorCode.Expired => "The session lease has expired.",
|
||||
RendezvousErrorCode.StaleHost => "The session has no fresh host presence.",
|
||||
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
|
||||
RendezvousErrorCode.RateLimited => "The request rate limit was exceeded.",
|
||||
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
|
||||
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
|
||||
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
|
||||
|
||||
@@ -17,18 +17,26 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||
}
|
||||
|
||||
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||
httpContext.Response.StatusCode = invalidRequest
|
||||
? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
bool payloadTooLarge = exception is BadHttpRequestException
|
||||
{
|
||||
StatusCode: StatusCodes.Status413PayloadTooLarge,
|
||||
};
|
||||
httpContext.Response.StatusCode = payloadTooLarge
|
||||
? StatusCodes.Status413PayloadTooLarge
|
||||
: invalidRequest
|
||||
? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsJsonAsync(
|
||||
new ApiError
|
||||
{
|
||||
Code = invalidRequest
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.InternalError,
|
||||
Message = invalidRequest
|
||||
? "The request body, route, or query value is invalid."
|
||||
: "The service could not complete the request.",
|
||||
Message = payloadTooLarge
|
||||
? "The request body exceeds the supported size."
|
||||
: invalidRequest
|
||||
? "The request body, route, or query value is invalid."
|
||||
: "The service could not complete the request.",
|
||||
},
|
||||
ContractJson.Options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
@@ -8,6 +9,7 @@ using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
@@ -96,6 +98,31 @@ builder.Services.AddOpenApi("v1", static options =>
|
||||
[attemptReference] = [],
|
||||
});
|
||||
}
|
||||
|
||||
foreach (OpenApiOperation operation in path.Operations.Values)
|
||||
{
|
||||
if (operation.Responses is null
|
||||
|| !operation.Responses.TryGetValue(
|
||||
StatusCodes.Status429TooManyRequests.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
out IOpenApiResponse? response)
|
||||
|| response is not OpenApiResponse concreteResponse)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
concreteResponse.Headers ??=
|
||||
new Dictionary<string, IOpenApiHeader>(StringComparer.OrdinalIgnoreCase);
|
||||
concreteResponse.Headers["Retry-After"] = new OpenApiHeader
|
||||
{
|
||||
Description = "Whole seconds before the caller should retry (1-60).",
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = JsonSchemaType.Integer,
|
||||
Format = "int32",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
@@ -107,6 +134,45 @@ builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.WebHost.ConfigureKestrel(static options =>
|
||||
options.Limits.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<AbuseProtectionOptions>()
|
||||
.BindConfiguration(AbuseProtectionOptions.SectionName)
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(
|
||||
options => options.HttpOptionalRequestsPerWindow
|
||||
< options.HttpGlobalRequestsPerWindow,
|
||||
"The optional HTTP request budget must leave global capacity for lease operations.")
|
||||
.Validate(
|
||||
options => options.HttpOptionalConcurrency < options.HttpGlobalConcurrency,
|
||||
"The optional HTTP concurrency budget must leave global capacity for lease operations.")
|
||||
.Validate(
|
||||
options => options.HttpOptionalIpPrefixRequestsPerWindow
|
||||
< options.HttpIpPrefixRequestsPerWindow,
|
||||
"The optional HTTP source budget must leave capacity for lease operations.")
|
||||
.Validate(
|
||||
options => options.HttpOptionalIpPrefixConcurrency
|
||||
< options.HttpIpPrefixConcurrency,
|
||||
"The optional HTTP source concurrency must leave capacity for lease operations.")
|
||||
.Validate(
|
||||
options => options.CriticalTrackedKeyReserve >= 16
|
||||
&& options.UdpTrackedKeyLimit + options.CriticalTrackedKeyReserve
|
||||
< options.MaxTrackedKeys,
|
||||
"The tracked-key reserve must leave at least 16 keys for critical operations.")
|
||||
.Validate(
|
||||
options => options.TrustedProxyAddresses is { Length: <= 32 } addresses
|
||||
&& addresses.All(
|
||||
static value => IPAddress.TryParse(value, out _)),
|
||||
"Trusted proxy addresses must contain at most 32 literal IP addresses.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||
AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
|
||||
.GetSection(AbuseProtectionOptions.SectionName)
|
||||
.Get<AbuseProtectionOptions>() ?? new AbuseProtectionOptions();
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
|
||||
|
||||
SystemRendezvousClock rendezvousClock = new();
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
@@ -176,13 +242,19 @@ if (!isOpenApiGeneration)
|
||||
WebApplication app = builder.Build();
|
||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||
|
||||
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
||||
{
|
||||
app.UseForwardedHeaders();
|
||||
}
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
app.MapOpenApi();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
app.MapGet(
|
||||
"/health/live",
|
||||
static () => Results.Ok(new HealthResponse { Status = "live" }))
|
||||
.Produces<HealthResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.WithName("GetLiveness")
|
||||
.WithTags("Health");
|
||||
app.MapGet(
|
||||
@@ -198,6 +270,7 @@ app.MapGet(
|
||||
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
||||
: Results.Ok(new HealthResponse { Status = "ready" }))
|
||||
.Produces<HealthResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("GetReadiness")
|
||||
.WithTags("Health");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
@@ -36,7 +37,8 @@ internal enum NatMediationResult
|
||||
internal sealed class NatMediationProcessor(
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
JoinAttemptService joinAttempts)
|
||||
JoinAttemptService joinAttempts,
|
||||
AbuseProtectionService? abuseProtection = null)
|
||||
{
|
||||
public NatMediationResult ProcessDatagram(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
@@ -44,6 +46,29 @@ internal sealed class NatMediationProcessor(
|
||||
INatIntroductionSink introductionSink,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryAcceptIngress(observedPublicEndpoint, "frozen"))
|
||||
{
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
return ProcessDatagramAfterIngress(
|
||||
encoded,
|
||||
observedPublicEndpoint,
|
||||
introductionSink,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
internal bool TryAcceptIngress(IPEndPoint observedPublicEndpoint, string operation) =>
|
||||
abuseProtection is null
|
||||
|| abuseProtection.TryAcceptUdpIngress(observedPublicEndpoint.Address, operation);
|
||||
|
||||
internal NatMediationResult ProcessDatagramAfterIngress(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
IPEndPoint observedPublicEndpoint,
|
||||
INatIntroductionSink introductionSink,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||
|| datagram is null
|
||||
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|
||||
@@ -63,7 +88,7 @@ internal sealed class NatMediationProcessor(
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
NatMediationResult result = ProcessRequest(
|
||||
NatMediationResult result = ProcessRequestCore(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
|
||||
@@ -76,7 +101,7 @@ internal sealed class NatMediationProcessor(
|
||||
return result;
|
||||
}
|
||||
|
||||
return ProcessRequest(
|
||||
return ProcessRequestCore(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
@@ -98,6 +123,39 @@ internal sealed class NatMediationProcessor(
|
||||
ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
|
||||
ArgumentNullException.ThrowIfNull(introductionSink);
|
||||
|
||||
if (!TryAcceptIngress(observedPublicEndpoint, "litenet-or-invalid"))
|
||||
{
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
return ProcessRequestAfterIngress(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
token,
|
||||
introductionSink,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
internal NatMediationResult ProcessRequestAfterIngress(
|
||||
IPEndPoint claimedLocalEndpoint,
|
||||
IPEndPoint observedPublicEndpoint,
|
||||
string token,
|
||||
INatIntroductionSink introductionSink,
|
||||
CancellationToken cancellationToken = default) => ProcessRequestCore(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
token,
|
||||
introductionSink,
|
||||
cancellationToken);
|
||||
|
||||
private NatMediationResult ProcessRequestCore(
|
||||
IPEndPoint claimedLocalEndpoint,
|
||||
IPEndPoint observedPublicEndpoint,
|
||||
string token,
|
||||
INatIntroductionSink introductionSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|
||||
|| request is null
|
||||
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
|
||||
@@ -106,6 +164,17 @@ internal sealed class NatMediationProcessor(
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
string operation = request.Role.ToString();
|
||||
if (abuseProtection is not null
|
||||
&& !abuseProtection.TryAcceptUdpIdentity(
|
||||
operation,
|
||||
observedPublicEndpoint.Address,
|
||||
request.Capability,
|
||||
request.MediationHandle.ToString()))
|
||||
{
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
|
||||
claimedLocalEndpoint,
|
||||
publicEndpoint.AddressFamily,
|
||||
|
||||
@@ -172,12 +172,21 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
||||
bool isFrozenEnvelope = length >= 2
|
||||
&& data[0] == RendezvousUdpCodec.MagicFirst
|
||||
&& data[1] == RendezvousUdpCodec.MagicSecond;
|
||||
if (!processor.TryAcceptIngress(
|
||||
endPoint,
|
||||
isFrozenEnvelope ? "frozen" : "litenet-or-invalid"))
|
||||
{
|
||||
Drop(ref length);
|
||||
return;
|
||||
}
|
||||
|
||||
INatIntroductionSink? sink = _sink;
|
||||
if (isFrozenEnvelope)
|
||||
{
|
||||
if (sink is not null)
|
||||
{
|
||||
_ = processor.ProcessDatagram(data.AsSpan(0, length), endPoint, sink);
|
||||
_ = processor.ProcessDatagramAfterIngress(
|
||||
data.AsSpan(0, length), endPoint, sink);
|
||||
}
|
||||
}
|
||||
else if (sink is not null
|
||||
@@ -188,7 +197,8 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
||||
&& claimedLocalEndpoint is not null
|
||||
&& token is not null)
|
||||
{
|
||||
_ = processor.ProcessRequest(claimedLocalEndpoint, endPoint, token, sink);
|
||||
_ = processor.ProcessRequestAfterIngress(
|
||||
claimedLocalEndpoint, endPoint, token, sink);
|
||||
}
|
||||
|
||||
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
||||
|
||||
@@ -5,6 +5,38 @@
|
||||
"Port": 9050,
|
||||
"MaxDatagramsPerPoll": 256,
|
||||
"PollIntervalMilliseconds": 2
|
||||
},
|
||||
"AbuseProtection": {
|
||||
"WindowSeconds": 1,
|
||||
"MaxTrackedKeys": 100000,
|
||||
"CriticalTrackedKeyReserve": 2048,
|
||||
"UdpTrackedKeyLimit": 70000,
|
||||
"TrustedProxyAddresses": [],
|
||||
"HealthGlobalRequestsPerWindow": 1000,
|
||||
"HealthGlobalConcurrency": 32,
|
||||
"HealthIpPrefixRequestsPerWindow": 120,
|
||||
"HealthIpPrefixConcurrency": 8,
|
||||
"HttpGlobalRequestsPerWindow": 20000,
|
||||
"HttpOptionalRequestsPerWindow": 18000,
|
||||
"HttpIpPrefixRequestsPerWindow": 500,
|
||||
"HttpOptionalIpPrefixRequestsPerWindow": 450,
|
||||
"HttpOperationRequestsPerWindow": 5000,
|
||||
"HttpTenantRequestsPerWindow": 2000,
|
||||
"HttpPrincipalRequestsPerWindow": 500,
|
||||
"HttpResourceRequestsPerWindow": 200,
|
||||
"HttpGlobalConcurrency": 1024,
|
||||
"HttpOptionalConcurrency": 768,
|
||||
"HttpIpPrefixConcurrency": 64,
|
||||
"HttpOptionalIpPrefixConcurrency": 48,
|
||||
"HttpOperationConcurrency": 256,
|
||||
"HttpTenantConcurrency": 256,
|
||||
"HttpPrincipalConcurrency": 32,
|
||||
"HttpResourceConcurrency": 16,
|
||||
"UdpGlobalDatagramsPerWindow": 100000,
|
||||
"UdpIpPrefixDatagramsPerWindow": 2000,
|
||||
"UdpOperationDatagramsPerWindow": 50000,
|
||||
"UdpCapabilityDatagramsPerWindow": 120,
|
||||
"UdpResourceDatagramsPerWindow": 240
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
|
||||
Reference in New Issue
Block a user