mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-27 07:59:03 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Single switch for the per-request chatter the patch used to print on every
|
||||
/// login (the [Net], [Connection], [Auth], [HttpStatus] and per-connection [TLS]
|
||||
/// lines). It is OFF by default: the log now carries patch status and real
|
||||
/// problems only. Set RADICADE_DIAG=1 to get the full trace back when something
|
||||
/// needs diagnosing.
|
||||
/// </summary>
|
||||
internal static class Diag
|
||||
{
|
||||
internal static readonly bool Verbose = ReadFlag("RADICADE_DIAG");
|
||||
|
||||
private static readonly object Gate = new object();
|
||||
private static readonly HashSet<string> Seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
internal static bool ReadFlag(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
var value = raw.Trim().ToLowerInvariant();
|
||||
return !(value == "0" || value == "off" || value == "false" || value == "no");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Per-request tracing: printed only with RADICADE_DIAG=1.</summary>
|
||||
internal static void Trace(string message)
|
||||
{
|
||||
if (!Verbose) return;
|
||||
Info(message);
|
||||
}
|
||||
|
||||
internal static void Info(string message)
|
||||
{
|
||||
try { RadicadeEacBypassPlugin.LogSource.LogInfo(message); } catch { }
|
||||
}
|
||||
|
||||
internal static void Warn(string message)
|
||||
{
|
||||
try { RadicadeEacBypassPlugin.LogSource.LogWarning(message); } catch { }
|
||||
}
|
||||
|
||||
internal static void Error(string message)
|
||||
{
|
||||
try { RadicadeEacBypassPlugin.LogSource.LogError(message); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>True the first time this key is seen in the session.</summary>
|
||||
internal static bool FirstTime(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Gate) return Seen.Add(key);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using HarmonyLib;
|
||||
using TLS = Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication;
|
||||
using TimeValid = Org.BouncyCastle.Crypto.Tls.TimeValidVerifyer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class EmbeddedTlsPatches
|
||||
{
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
harmony.Patch(AccessTools.Method(typeof(TLS), "NotifyServerCertificate"),
|
||||
prefix: new HarmonyMethod(typeof(EmbeddedTlsPatches), nameof(NotifyServerCertificatePrefix)));
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[TLS] Patched LegacyTlsAuthentication.NotifyServerCertificate (host-scoped accept)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] NotifyServerCertificate patch failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
harmony.Patch(AccessTools.Method(typeof(TimeValid), "IsValid"),
|
||||
prefix: new HarmonyMethod(typeof(EmbeddedTlsPatches), nameof(IsValidPrefix)));
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[TLS] Patched TimeValidVerifyer.IsValid (host-scoped accept)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] TimeValidVerifyer patch failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
static bool NotifyServerCertificatePrefix(TLS __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = __instance.TargetUri;
|
||||
var host = uri?.Host;
|
||||
var verifyerType = __instance.verifyer != null ? __instance.verifyer.GetType().FullName : "<null>";
|
||||
|
||||
Diag.Trace(
|
||||
$"[TLS] NotifyServerCertificate: host={host ?? "<null>"}, verifyer={verifyerType}");
|
||||
|
||||
if (host != null && IsHostAllowed(host))
|
||||
{
|
||||
Diag.Trace(
|
||||
$"[TLS] Accepting server certificate for allowed Radicade host '{host}' (embedded-stack bypass)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] NotifyServerCertificate prefix error: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
static bool IsValidPrefix(Il2CppSystem.Uri targetUri, ref bool __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = targetUri?.Host;
|
||||
if (host != null && IsHostAllowed(host))
|
||||
{
|
||||
Diag.Trace(
|
||||
$"[TLS] TimeValidVerifyer.IsValid forced TRUE for allowed Radicade host '{host}'");
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] IsValid prefix error: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static bool IsHostAllowed(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) return false;
|
||||
var raw = RadicadeEacBypassPlugin.TlsAllowedHosts?.Value;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Split(',')
|
||||
.Select(h => h.Trim().TrimEnd('.'))
|
||||
.Where(h => h.Length > 0)
|
||||
.Any(h => host.TrimEnd('.').Equals(h, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Reflection;
|
||||
using BestHTTP;
|
||||
using HarmonyLib;
|
||||
|
||||
/// <summary>
|
||||
/// Fabric-level repairs for two things the game cannot survive as-is:
|
||||
/// a chunked response the server truncated, and two responses whose shape the
|
||||
/// client's reader rejects.
|
||||
/// </summary>
|
||||
internal static class HttpTolerancePatches
|
||||
{
|
||||
private static long _healed;
|
||||
private static int _lastHealReport;
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = TargetMethod();
|
||||
if (target == null)
|
||||
{
|
||||
Diag.Warn("[Http] BestHTTP.HTTPResponse.ReadChunkLength(Stream) was not found: " +
|
||||
"a truncated chunked body will keep failing its request.");
|
||||
return;
|
||||
}
|
||||
|
||||
harmony.Patch(target,
|
||||
finalizer: new HarmonyMethod(AccessTools.Method(typeof(HttpTolerancePatches), nameof(Finalizer))));
|
||||
Diag.Info("[Http] truncated chunked responses are completed instead of failing their request.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Diag.Warn("[Http] could not arm the tolerant chunked reader (" + ex.GetType().Name + ": " + ex.Message + ").");
|
||||
}
|
||||
}
|
||||
|
||||
internal static MethodBase TargetMethod() =>
|
||||
AccessTools.Method(typeof(HTTPResponse), "ReadChunkLength", new[] { typeof(Stream) });
|
||||
|
||||
/// <summary>
|
||||
/// "Can't parse '' as a hex number" is BestHTTP reading a chunk-size line from a
|
||||
/// connection the server had already closed - the pooled keep-alive connection
|
||||
/// it picked was dead. Ending the body there (chunk length 0) delivers the
|
||||
/// response that did arrive instead of failing the request, which is what the
|
||||
/// heartbeats need: their ack body is empty anyway.
|
||||
/// </summary>
|
||||
private static Exception Finalizer(Exception __exception, ref int __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__exception == null) return null;
|
||||
if (!ResponseRepair.IsTruncatedChunkFailure(__exception.Message)) return __exception;
|
||||
|
||||
ReportHealed();
|
||||
__result = 0;
|
||||
__exception = null;
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return __exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReportHealed()
|
||||
{
|
||||
var count = System.Threading.Interlocked.Increment(ref _healed);
|
||||
var now = Environment.TickCount;
|
||||
if (count != 1 && unchecked(now - _lastHealReport) < 60000) return;
|
||||
_lastHealReport = now;
|
||||
Diag.Warn("[Http] a chunked response ended early (dead pooled connection); the request was completed "
|
||||
+ "with the bytes that arrived instead of failing. Occurrences so far: " + count + ".");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies <see cref="ResponseRepair"/> to a live response before the client's own
|
||||
/// callback reads it. Called from the request funnel, always on the transport
|
||||
/// thread that owns the response.
|
||||
/// </summary>
|
||||
internal static void RepairResponse(HTTPResponse response, string path, string query)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (response == null || string.IsNullOrEmpty(path)) return;
|
||||
if (response.StatusCode != 200) return;
|
||||
|
||||
string body;
|
||||
try { body = response.DataAsText; }
|
||||
catch { return; }
|
||||
|
||||
var repaired = ResponseRepair.RepairBody(path, query, body, out var note);
|
||||
if (repaired == null) return;
|
||||
|
||||
response.Data = Encoding.UTF8.GetBytes(repaired);
|
||||
|
||||
if (Diag.FirstTime("repair:" + path))
|
||||
Diag.Warn("[Repair] " + path + ": " + note + ".");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Diag.Warn("[Repair] could not repair a " + path + " response: " + ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using BestHTTP;
|
||||
using Il2CppInterop.Runtime;
|
||||
|
||||
internal static class HubSocketAuth
|
||||
{
|
||||
internal static string Token { get; private set; }
|
||||
|
||||
internal static string LastLoginRefusalCode { get; private set; }
|
||||
|
||||
private static readonly string[] AuthorizationHeaders = { "Authorization", "authorization", "AUTHORIZATION" };
|
||||
|
||||
private static readonly System.Collections.Generic.HashSet<string> Stated = new();
|
||||
|
||||
internal static bool IsServer(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var request) ||
|
||||
!Uri.TryCreate(RadicadeEacBypassPlugin.ServerUrl.Value, UriKind.Absolute, out var server)) return false;
|
||||
return request.Host.Equals(server.Host, StringComparison.OrdinalIgnoreCase) && request.Port == server.Port &&
|
||||
(request.Scheme == server.Scheme || request.Scheme == (server.Scheme == "https" ? "wss" : "ws"));
|
||||
}
|
||||
internal static void Capture(HTTPRequest request)
|
||||
{
|
||||
if (request?.Uri == null || !IsServer(request.Uri.ToString())) return;
|
||||
foreach (var name in AuthorizationHeaders)
|
||||
{
|
||||
var values = request.GetHeaderValues(name);
|
||||
if (values == null || values.Count == 0) continue;
|
||||
var value = values[0];
|
||||
if (value != null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
Token = value.Substring(7).Trim();
|
||||
}
|
||||
}
|
||||
internal static void CaptureFromLoginResponse(HTTPRequest request, string url)
|
||||
{
|
||||
if (!IsServer(url) || !new Uri(url).AbsolutePath.EndsWith("/connect/token", StringComparison.OrdinalIgnoreCase)) return;
|
||||
Token = null;
|
||||
var original = request.Callback;
|
||||
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
|
||||
new Action<HTTPRequest, HTTPResponse>((req, response) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (response != null && response.StatusCode == 200)
|
||||
{
|
||||
using var document = JsonDocument.Parse(response.DataAsText);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (document.RootElement.TryGetProperty("access_token", out var token) &&
|
||||
token.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(token.GetString()))
|
||||
{
|
||||
Token = token.GetString();
|
||||
LastLoginRefusalCode = null;
|
||||
Diag.Trace("[Realtime] Login token captured (value omitted)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Token = null;
|
||||
var error = document.RootElement.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
|
||||
var description = document.RootElement.TryGetProperty("error_description", out var descriptionElement) ? descriptionElement.GetString() : null;
|
||||
LastLoginRefusalCode = error;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[Realtime] connect/token answered HTTP 200 without an access_token - that is a login REFUSAL, not a login. " +
|
||||
"error='" + (error ?? "-") + "', error_description='" + (description ?? "-") + "'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] Token parse failed: " + ex.GetType().Name); }
|
||||
finally { original?.Invoke(req, response); }
|
||||
}));
|
||||
}
|
||||
internal static void AuthenticateHubRequest(HTTPRequest request, string url)
|
||||
{
|
||||
if (!IsServer(url) || !NameserverRedirect.IsHubUrl(url)) return;
|
||||
if (!string.IsNullOrWhiteSpace(Token))
|
||||
{
|
||||
request.Uri = new Il2CppSystem.Uri(NameserverRedirect.EnsureHubToken(url, Token));
|
||||
request.SetHeader("Authorization", "Bearer " + Token);
|
||||
}
|
||||
Diag.Trace("[Realtime] Hub request " + new Uri(url).AbsolutePath +
|
||||
"; token=" + (NameserverRedirect.HasHubToken(request.Uri.ToString()) ? "present" : "missing"));
|
||||
}
|
||||
|
||||
internal static void AuthenticateServerRequest(HTTPRequest request, string url)
|
||||
{
|
||||
if (request == null || !IsServer(url) || string.IsNullOrWhiteSpace(Token)) return;
|
||||
try
|
||||
{
|
||||
if (IsAnonymousPath(new Uri(url).AbsolutePath)) return;
|
||||
|
||||
foreach (var name in AuthorizationHeaders)
|
||||
{
|
||||
var values = request.GetHeaderValues(name);
|
||||
if (values != null && values.Count > 0) return;
|
||||
}
|
||||
|
||||
request.SetHeader("Authorization", "Bearer " + Token);
|
||||
var path = new Uri(url).AbsolutePath;
|
||||
if (Stated.Add(path))
|
||||
Diag.Trace("[Auth] stated the session token on " + path + " (the client sent none)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Diag.Warn("[Auth] could not state the token on " + url + ": " + ex.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAnonymousPath(string path) =>
|
||||
path.StartsWith("/auth/connect/token", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/auth/cachedlogin", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/auth/eac/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
|
||||
public static class ImageSignatureRequest
|
||||
{
|
||||
|
||||
|
||||
|
||||
const int HeaderScanLimit = 0x100;
|
||||
|
||||
const int MinimumRecords = 32;
|
||||
|
||||
|
||||
|
||||
|
||||
const int PrefixProbe = 8;
|
||||
|
||||
const int PackingProbe = 1024;
|
||||
|
||||
const int MaxLiteralLength = 1 << 20;
|
||||
|
||||
static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true);
|
||||
|
||||
|
||||
public static string? DetectKeyId(byte[]? metadata)
|
||||
{
|
||||
if (metadata == null || metadata.Length < 24 ||
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(metadata) != 0xFAB11BAF ||
|
||||
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(4)) != 27)
|
||||
return null;
|
||||
int table = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(8));
|
||||
int size = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(12));
|
||||
int data = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(16));
|
||||
int dataSize = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(20));
|
||||
if (table < 24 || size < 0 || size % 8 != 0 || (long)table + size > metadata.Length ||
|
||||
data < 24 || dataSize < 0 || (long)data + dataSize > metadata.Length)
|
||||
return null;
|
||||
|
||||
return ScanKeyTails(metadata, table, size / 8, data, dataSize);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static string? DetectKeyIdFromGameMetadata(byte[]? metadata)
|
||||
{
|
||||
if (metadata == null || metadata.Length < 24) return null;
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(metadata) == 0xFAB11BAF &&
|
||||
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(4)) == 27)
|
||||
return DetectKeyId(metadata);
|
||||
return DetectKeyIdByHeaderScan(metadata);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
static string? ScanKeyTails(byte[] metadata, int tableOffset, int recordCount, int dataOffset, int dataSize)
|
||||
{
|
||||
string? key = null;
|
||||
for (int record = 0; record < recordCount; record++)
|
||||
{
|
||||
var (length, index) = Record(metadata, tableOffset, record);
|
||||
if (length < 0 || index < 0 || (long)index + length > dataSize) return null;
|
||||
if (length != 8 && length != 18) continue;
|
||||
var literal = Encoding.UTF8.GetString(metadata, dataOffset + index, length);
|
||||
if (literal != ".rec.net" && literal != ".radicade.internal") continue;
|
||||
var candidate = "KEY:RSA:p1" + literal;
|
||||
if (key != null && key != candidate) return null;
|
||||
key = candidate;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
static (int Length, int Index) Record(byte[] metadata, int tableOffset, int record)
|
||||
{
|
||||
int offset = tableOffset + 8 * record;
|
||||
return (BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset)),
|
||||
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset + 4)));
|
||||
}
|
||||
|
||||
public static string? DetectKeyIdByHeaderScan(byte[]? metadata)
|
||||
{
|
||||
if (metadata == null || metadata.Length < HeaderScanLimit + 8) return null;
|
||||
|
||||
var fields = new List<int>();
|
||||
for (int offset = 0; offset + 4 <= HeaderScanLimit; offset += 4)
|
||||
{
|
||||
int value = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset));
|
||||
if (value > 0 && value <= metadata.Length && !fields.Contains(value)) fields.Add(value);
|
||||
}
|
||||
|
||||
var tables = new List<(int Offset, int Count)>();
|
||||
foreach (var tableOffset in fields)
|
||||
foreach (var tableSize in fields)
|
||||
{
|
||||
if (tableSize % 8 != 0 || tableSize / 8 < MinimumRecords) continue;
|
||||
if (tableOffset < HeaderScanLimit || (long)tableOffset + tableSize > metadata.Length) continue;
|
||||
tables.Add((tableOffset, tableSize / 8));
|
||||
}
|
||||
|
||||
var slices = new List<(int Offset, int Size)>();
|
||||
foreach (var dataOffset in fields)
|
||||
foreach (var dataSize in fields)
|
||||
{
|
||||
if (dataSize <= 0 || dataOffset < HeaderScanLimit) continue;
|
||||
if ((long)dataOffset + dataSize > metadata.Length) continue;
|
||||
slices.Add((dataOffset, dataSize));
|
||||
}
|
||||
|
||||
|
||||
|
||||
tables.Sort((a, b) => b.Count.CompareTo(a.Count));
|
||||
|
||||
foreach (var (tableOffset, count) in tables)
|
||||
foreach (var (dataOffset, dataSize) in slices)
|
||||
{
|
||||
if (!LooksLikeLiteralTable(metadata, tableOffset, count, dataOffset, dataSize, PrefixProbe)) continue;
|
||||
if (!SampleDecodesAsText(metadata, tableOffset, count, dataOffset, dataSize)) continue;
|
||||
if (!LooksLikeLiteralTable(metadata, tableOffset, count, dataOffset, dataSize, PackingProbe)) continue;
|
||||
var key = ScanKeyTails(metadata, tableOffset, count, dataOffset, dataSize);
|
||||
if (key != null) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
static bool LooksLikeLiteralTable(byte[] metadata, int tableOffset, int count, int dataOffset, int dataSize, int probe)
|
||||
{
|
||||
int examined = Math.Min(count, probe);
|
||||
int contiguous = 0;
|
||||
long running = 0;
|
||||
for (int record = 0; record < examined; record++)
|
||||
{
|
||||
var (length, index) = Record(metadata, tableOffset, record);
|
||||
if (length < 0 || length > MaxLiteralLength || index < 0 || (long)index + length > dataSize) return false;
|
||||
if (index == running) contiguous++;
|
||||
running = index + length;
|
||||
}
|
||||
return contiguous >= examined - Math.Max(1, examined / 100);
|
||||
}
|
||||
|
||||
static bool SampleDecodesAsText(byte[] metadata, int tableOffset, int count, int dataOffset, int dataSize)
|
||||
{
|
||||
int step = Math.Max(1, count / 48);
|
||||
for (int record = 0; record < count; record += step)
|
||||
{
|
||||
var (length, index) = Record(metadata, tableOffset, record);
|
||||
if (length < 0 || index < 0 || (long)index + length > dataSize) return false;
|
||||
if (length == 0) continue;
|
||||
try
|
||||
{
|
||||
StrictUtf8.GetCharCount(metadata, dataOffset + index, length);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string AddKeyId(string url, string serverUrl, string? keyId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyId) ||
|
||||
!Uri.TryCreate(url, UriKind.Absolute, out var request) ||
|
||||
!Uri.TryCreate(serverUrl, UriKind.Absolute, out var server) ||
|
||||
(server.Scheme != "http" && server.Scheme != "https") ||
|
||||
request.Scheme != server.Scheme || request.Authority != server.Authority ||
|
||||
!request.AbsolutePath.StartsWith(server.AbsolutePath.TrimEnd('/') + "/images/", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
|
||||
|
||||
var parameters = request.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(p => !Uri.UnescapeDataString(p.Split('=')[0]).Equals("rcsig", StringComparison.OrdinalIgnoreCase))
|
||||
.Append("rcsig=" + Uri.EscapeDataString(keyId));
|
||||
return request.GetLeftPart(UriPartial.Path) + "?" + string.Join("&", parameters) + request.Fragment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using BestHTTP;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
internal static class HileGate
|
||||
{
|
||||
internal static bool Enabled;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
internal static int DroppedCount;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static bool DropEnabled { get; } = IsDropEnabled();
|
||||
|
||||
private static bool IsDropEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = System.Environment.GetEnvironmentVariable("RADICADE_HILE_GATE");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() == "drop";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static bool _dumped;
|
||||
|
||||
public static bool ShouldDrop(HTTPRequest request)
|
||||
{
|
||||
if (!Enabled || request?.Uri == null) return false;
|
||||
|
||||
var path = request.Uri.AbsolutePath;
|
||||
if (path == null ||
|
||||
!path.EndsWith("/api/PlayerReporting/v1/hile", System.StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
Dump(request);
|
||||
return DropEnabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static void Dump(HTTPRequest request)
|
||||
{
|
||||
if (_dumped) return;
|
||||
_dumped = true;
|
||||
|
||||
try
|
||||
{
|
||||
var body = "<unreadable>";
|
||||
|
||||
try
|
||||
{
|
||||
var raw = request.RawData;
|
||||
if (raw != null && raw.Length > 0)
|
||||
{
|
||||
body = System.Text.Encoding.UTF8.GetString(raw);
|
||||
if (body.Length > 2000) body = body.Substring(0, 2000) + "...[truncated]";
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = request.GetEntityBody();
|
||||
if (entity != null && entity.Length > 0)
|
||||
{
|
||||
var text = System.Text.Encoding.UTF8.GetString(entity);
|
||||
body = text + " (" + entity.Length + " bytes in the entity body)";
|
||||
}
|
||||
else
|
||||
{
|
||||
body = "<empty body>";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception bodyEx)
|
||||
{
|
||||
body = "<body read failed: " + bodyEx.Message + ">";
|
||||
}
|
||||
|
||||
var caller = "<unavailable>";
|
||||
try { caller = System.Environment.StackTrace; } catch { }
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[HileGate] anti-cheat report captured (full wire form):\n " + request.MethodType
|
||||
+ " " + request.Uri + "\n body: " + body
|
||||
+ "\n managed caller stack:" + caller);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[HileGate] capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch(typeof(HTTPManager), "SendRequest", new[] { typeof(HTTPRequest) })]
|
||||
internal static class NameserverConnectionPatches
|
||||
{
|
||||
|
||||
[HarmonyPriority(Priority.Last)]
|
||||
private static bool Prefix(HTTPRequest request, ref HTTPRequest __result)
|
||||
{
|
||||
if (RequestFunnel.Handle(request)) return true;
|
||||
|
||||
__result = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RequestFunnel
|
||||
{
|
||||
private static readonly HashSet<long> Seen = new();
|
||||
|
||||
private static bool IsSocketScheme(string url) =>
|
||||
url.StartsWith("ws:", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("wss:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
internal static bool Handle(HTTPRequest request)
|
||||
{
|
||||
if (HileGate.ShouldDrop(request))
|
||||
{
|
||||
HileGate.DroppedCount++;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[HileGate] dropped anti-cheat report POST (no response will arrive).");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var uri = request?.Uri?.ToString();
|
||||
if (!string.IsNullOrEmpty(uri))
|
||||
Diag.Trace("[Net] " + request.MethodType + " " + uri);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
if (request?.Uri == null) return true;
|
||||
|
||||
if (!Seen.Add(request.Pointer.ToInt64())) return true;
|
||||
|
||||
var original = request.Uri.ToString();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var rewritten = IsSocketScheme(original)
|
||||
? NameserverRedirect.RewriteWebSocket(original, RadicadeEacBypassPlugin.ServerUrl.Value)
|
||||
: NameserverRedirect.Rewrite(original, RadicadeEacBypassPlugin.ServerUrl.Value);
|
||||
var withKeyId = ImageSignatureRequest.AddKeyId(rewritten, RadicadeEacBypassPlugin.ServerUrl.Value,
|
||||
RadicadeEacBypassPlugin.ImageKeyId);
|
||||
if (withKeyId != rewritten)
|
||||
{
|
||||
|
||||
|
||||
Diag.Trace("[ImageSig] stated key id on " + withKeyId);
|
||||
}
|
||||
rewritten = withKeyId;
|
||||
|
||||
if (rewritten != original)
|
||||
{
|
||||
request.Uri = new Il2CppSystem.Uri(rewritten);
|
||||
AlignHostHeader(request, rewritten);
|
||||
Diag.Trace("[Connection] Redirected nameserver request to the configured Radicade server.");
|
||||
}
|
||||
|
||||
if (RealtimeDiagnostics.Verbose && IsSocketScheme(original))
|
||||
{
|
||||
try
|
||||
{
|
||||
var outgoing = new System.Text.StringBuilder("[Realtime] socket request: " + original);
|
||||
var headers = request.Headers;
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var header in headers)
|
||||
{
|
||||
outgoing.Append(" | ").Append(header.Key).Append("=");
|
||||
var values = header.Value;
|
||||
if (values != null)
|
||||
foreach (var value in values) outgoing.Append(value).Append(",");
|
||||
}
|
||||
}
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(outgoing.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] socket request dump failed: "
|
||||
+ ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
HubSocketAuth.Capture(request);
|
||||
HubSocketAuth.CaptureFromLoginResponse(request, request.Uri.ToString());
|
||||
|
||||
|
||||
|
||||
HubSocketAuth.AuthenticateHubRequest(request, request.Uri.ToString());
|
||||
|
||||
HubSocketAuth.AuthenticateServerRequest(request, request.Uri.ToString());
|
||||
|
||||
if (Diag.Verbose) AuthGrantTrace.Report(request);
|
||||
HttpOutcomeTrace.Wrap(request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AlignHostHeader(HTTPRequest request, string url)
|
||||
{
|
||||
if (request == null || string.IsNullOrEmpty(url)) return;
|
||||
|
||||
Uri target;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out target) || string.IsNullOrEmpty(target.Host)) return;
|
||||
|
||||
var authority = target.IsDefaultPort ? target.Host : target.Host + ":" + target.Port;
|
||||
try
|
||||
{
|
||||
var stated = request.GetHeaderValues("Host");
|
||||
if (stated != null && stated.Count > 0)
|
||||
{
|
||||
if (stated.Count == 1 && stated[0] == authority) return;
|
||||
|
||||
request.RemoveHeader("Host");
|
||||
}
|
||||
|
||||
request.SetHeader("Host", authority);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[Connection] could not align the Host header on " + url +
|
||||
": " + ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AuthGrantTrace
|
||||
{
|
||||
private static readonly HashSet<long> Reported = new();
|
||||
|
||||
private static readonly string[] Fields =
|
||||
{
|
||||
"account_id", "username", "password", "birthday", "avatar", "avatarJson",
|
||||
"email", "platform_auth", "device_id", "device_class", "client_id", "ver"
|
||||
};
|
||||
|
||||
internal static void Report(HTTPRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request?.Uri == null) return;
|
||||
if (!request.Uri.ToString().EndsWith("/auth/connect/token", StringComparison.OrdinalIgnoreCase)) return;
|
||||
if (!Reported.Add(request.Pointer.ToInt64())) return;
|
||||
|
||||
var body = ReadBody(request);
|
||||
if (string.IsNullOrEmpty(body))
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[AuthGrant] connect/token body was unreadable; the grant is unknown for this request.");
|
||||
return;
|
||||
}
|
||||
|
||||
string Value(string name)
|
||||
{
|
||||
foreach (var pair in body.Split('&'))
|
||||
{
|
||||
var separator = pair.IndexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
if (!string.Equals(pair.Substring(0, separator), name, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
return pair.Substring(separator + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool Present(string name)
|
||||
{
|
||||
var value = Value(name);
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
var grant = Value("grant_type") ?? "<absent>";
|
||||
var present = new List<string>();
|
||||
foreach (var field in Fields)
|
||||
if (Present(field)) present.Add(field);
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[AuthGrant] connect/token grant_type=" + grant
|
||||
+ "; fields present: " + (present.Count == 0 ? "<none>" : string.Join(", ", present))
|
||||
+ " (values omitted)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[AuthGrant] could not read the grant: " + ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadBody(HTTPRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = request.RawData;
|
||||
if (raw != null && raw.Length > 0) return System.Text.Encoding.UTF8.GetString(raw);
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var entity = request.GetEntityBody();
|
||||
if (entity != null && entity.Length > 0) return System.Text.Encoding.UTF8.GetString(entity);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class SendRequestImplFunnelPatch
|
||||
{
|
||||
private static IEnumerable<MethodBase> TargetMethods()
|
||||
{
|
||||
var impl = AccessTools.Method(typeof(HTTPManager), "SendRequestImpl", new[] { typeof(HTTPRequest) });
|
||||
return impl == null ? Enumerable.Empty<MethodBase>() : new MethodBase[] { impl };
|
||||
}
|
||||
|
||||
private static void Prefix(object[] __args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__args == null || __args.Length == 0) return;
|
||||
RequestFunnel.Handle(__args[0] as HTTPRequest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Diag.Warn("[Net] funnel trace failed: " + ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HttpOutcomeTrace
|
||||
{
|
||||
private static readonly HashSet<long> Wrapped = new();
|
||||
private static int _completed;
|
||||
|
||||
internal static void Wrap(HTTPRequest request)
|
||||
{
|
||||
if (request == null) return;
|
||||
|
||||
string url;
|
||||
string method;
|
||||
string path;
|
||||
string query;
|
||||
try
|
||||
{
|
||||
if (request.Uri == null) return;
|
||||
url = Shorten(request.Uri.ToString());
|
||||
method = request.MethodType.ToString();
|
||||
path = request.Uri.AbsolutePath;
|
||||
query = request.Uri.Query;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!Wrapped.Add(request.Pointer.ToInt64())) return;
|
||||
|
||||
var original = request.Callback;
|
||||
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
|
||||
new Action<HTTPRequest, HTTPResponse>((req, response) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
HttpTolerancePatches.RepairResponse(response, path, query);
|
||||
|
||||
var completed = System.Threading.Interlocked.Increment(ref _completed);
|
||||
var status = -1;
|
||||
var bytes = 0;
|
||||
var reason = string.Empty;
|
||||
if (response != null)
|
||||
{
|
||||
status = response.StatusCode;
|
||||
try { var data = response.Data; if (data != null) bytes = data.Length; } catch { }
|
||||
try { if (!response.IsSuccess && !string.IsNullOrEmpty(response.Message)) reason = " reason=" + response.Message; } catch { }
|
||||
}
|
||||
|
||||
Diag.Trace("[HttpStatus] #" + completed + " " + method + " " + url + " -> "
|
||||
+ (response == null ? "NO RESPONSE" : status.ToString())
|
||||
+ " bytes=" + bytes + reason);
|
||||
|
||||
if (RealtimeDiagnostics.Verbose && url.IndexOf("/notify/hub/v1", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var answer = new System.Text.StringBuilder();
|
||||
answer.Append("[Realtime] socket answer: status=").Append(response == null ? -1 : status)
|
||||
.Append(" message=").Append(response == null ? "<none>" : response.Message);
|
||||
var answerHeaders = response?.Headers;
|
||||
if (answerHeaders != null)
|
||||
{
|
||||
foreach (var header in answerHeaders)
|
||||
{
|
||||
answer.Append(" | ").Append(header.Key).Append("=");
|
||||
var values = header.Value;
|
||||
if (values != null)
|
||||
foreach (var value in values) answer.Append(value).Append(",");
|
||||
}
|
||||
}
|
||||
string body = null;
|
||||
try { body = response?.DataAsText; } catch { }
|
||||
if (!string.IsNullOrEmpty(body))
|
||||
answer.Append(" | body=").Append(body.Length > 200 ? body.Substring(0, 200) : body);
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(answer.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] socket answer dump failed: "
|
||||
+ ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { original?.Invoke(req, response); } catch { }
|
||||
}
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Diag.Warn("[HttpStatus] could not instrument " + method + " " + url + ": "
|
||||
+ ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Shorten(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var path = uri.AbsolutePath;
|
||||
if (path.StartsWith("/auth/cachedlogin/forplatformid/", StringComparison.OrdinalIgnoreCase))
|
||||
return "/auth/cachedlogin/forplatformid/{redacted}";
|
||||
return path + (string.IsNullOrEmpty(uri.Query) ? string.Empty : "?" + uri.Query.Length + " chars");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<unparsable>";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
|
||||
public static class NameserverRedirect
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static readonly Dictionary<string, string> ServicePrefixes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["api.rec.net"] = "",
|
||||
["cdn.rec.net"] = "",
|
||||
["images.rec.net"] = "/images",
|
||||
["img.rec.net"] = "/images",
|
||||
["data.rec.net"] = "/data",
|
||||
["storage.rec.net"] = "/storage",
|
||||
["notify.rec.net"] = "/notify",
|
||||
["notifications.rec.net"] = "/notify",
|
||||
["notify.radicade.internal"] = "/notify",
|
||||
["notifications.radicade.internal"] = "/notify",
|
||||
["auth.rec.net"] = "/auth",
|
||||
["accounts.rec.net"] = "/accounts",
|
||||
["rooms.rec.net"] = "/rooms",
|
||||
["matchmaking.rec.net"] = "/matchmaking",
|
||||
["chat.rec.net"] = "/chat",
|
||||
|
||||
["api.radicade.app"] = "",
|
||||
["cdn.radicade.app"] = "/cdn",
|
||||
["ai.radicade.app"] = "/ai",
|
||||
["auth.radicade.app"] = "/auth",
|
||||
["www.radicade.app"] = "/www",
|
||||
["cards.radicade.app"] = "/cards",
|
||||
["notifications.radicade.app"] = "/notify",
|
||||
["images.radicade.app"] = "/images",
|
||||
["commerce.radicade.app"] = "/commerce",
|
||||
["data.radicade.app"] = "/data",
|
||||
["datacollection.radicade.app"] = "/datacollection",
|
||||
["discovery.radicade.app"] = "/discovery",
|
||||
["matchmaking.radicade.app"] = "/matchmaking",
|
||||
["storage.radicade.app"] = "/storage",
|
||||
["chat.radicade.app"] = "/chat",
|
||||
["geo.radicade.app"] = "/geo",
|
||||
["leaderboard.radicade.app"] = "/leaderboard",
|
||||
["accounts.radicade.app"] = "/accounts",
|
||||
["link.radicade.app"] = "/link",
|
||||
["locker.radicade.app"] = "/locker",
|
||||
["moderation.radicade.app"] = "/moderation",
|
||||
["roomcomments.radicade.app"] = "/roomcomments",
|
||||
["roomieintegrations.radicade.app"] = "/roomieintegrations",
|
||||
["clubs.radicade.app"] = "/clubs",
|
||||
["rooms.radicade.app"] = "/rooms",
|
||||
["econ.radicade.app"] = "/econ",
|
||||
["bugreporting.radicade.app"] = "/bugreporting",
|
||||
["cms.radicade.app"] = "/cms",
|
||||
["gamelogs.radicade.app"] = "/gamelogs",
|
||||
["platformnotifications.radicade.app"] = "/platformnotifications",
|
||||
["stringscdn.radicade.app"] = "/stringscdn",
|
||||
["strings.radicade.app"] = "/strings",
|
||||
["lists.radicade.app"] = "/lists",
|
||||
["playersettings.radicade.app"] = "/playersettings",
|
||||
["studio.radicade.app"] = "/studio",
|
||||
["studiocdn.radicade.app"] = "/studiocdn",
|
||||
["thorn.radicade.app"] = "/thorn",
|
||||
["videos.radicade.app"] = "/videos",
|
||||
};
|
||||
|
||||
public static string Rewrite(string requestUrl, string serverUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var request) ||
|
||||
(request.Scheme != "http" && request.Scheme != "https"))
|
||||
return requestUrl;
|
||||
|
||||
|
||||
bool nameserver = request.Host.Equals("ns.rec.net", StringComparison.OrdinalIgnoreCase) ||
|
||||
request.Host.Equals("ns.radicade.internal", StringComparison.OrdinalIgnoreCase);
|
||||
bool patchedServer = request.Host.Equals("ns.radicade.app", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
ServicePrefixes.TryGetValue(request.Host, out var servicePrefix);
|
||||
|
||||
if (!nameserver && !patchedServer && servicePrefix == null)
|
||||
return requestUrl;
|
||||
|
||||
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var server) ||
|
||||
(server.Scheme != "http" && server.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(server.UserInfo) || !string.IsNullOrEmpty(server.Query) ||
|
||||
!string.IsNullOrEmpty(server.Fragment))
|
||||
throw new ArgumentException("Connection.ServerUrl must be an HTTP(S) base URL without credentials, query or fragment.", nameof(serverUrl));
|
||||
|
||||
var path = request.AbsolutePath;
|
||||
|
||||
if (nameserver)
|
||||
{
|
||||
|
||||
if (path == "/") path = "/ns";
|
||||
}
|
||||
else if (servicePrefix is { Length: > 0 })
|
||||
{
|
||||
|
||||
|
||||
if (servicePrefix == "/notify" && path.StartsWith("/notify/", StringComparison.OrdinalIgnoreCase))
|
||||
path = path.Substring("/notify".Length);
|
||||
else if (path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase))
|
||||
path = path.Substring("/images".Length);
|
||||
else if (path.StartsWith("/img/", StringComparison.OrdinalIgnoreCase))
|
||||
path = path.Substring("/img".Length);
|
||||
|
||||
path = servicePrefix + path;
|
||||
}
|
||||
|
||||
return server.AbsoluteUri.TrimEnd('/') + path + request.Query;
|
||||
}
|
||||
|
||||
public static string RewriteWebSocket(string requestUrl, string serverUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var request))
|
||||
return requestUrl;
|
||||
|
||||
var scheme = request.Scheme.ToLowerInvariant();
|
||||
var secure = scheme == "wss" || scheme == "https";
|
||||
if (scheme != "ws" && scheme != "wss" && scheme != "http" && scheme != "https")
|
||||
return requestUrl;
|
||||
|
||||
|
||||
|
||||
var asHttp = new UriBuilder(request) { Scheme = secure ? "https" : "http" }.Uri.AbsoluteUri;
|
||||
var rewritten = Rewrite(asHttp, serverUrl);
|
||||
if (rewritten == asHttp) return requestUrl;
|
||||
|
||||
if (!Uri.TryCreate(rewritten, UriKind.Absolute, out var result)) return requestUrl;
|
||||
return new UriBuilder(result) { Scheme = result.Scheme == "https" ? "wss" : "ws" }.Uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
public static bool IsHubUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return false;
|
||||
|
||||
var lower = url.ToLowerInvariant();
|
||||
var query = lower.IndexOf('?');
|
||||
var path = query < 0 ? lower : lower.Substring(0, query);
|
||||
|
||||
return path.Contains("/hub/v1") || path.EndsWith("/hub")
|
||||
|| path.Contains("/hub/")
|
||||
|| path.Contains("notifyhub")
|
||||
|| path.Contains("/notify/hub");
|
||||
}
|
||||
|
||||
public static bool HasHubToken(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return false;
|
||||
|
||||
var query = url.IndexOf('?');
|
||||
if (query < 0) return false;
|
||||
|
||||
foreach (var pair in url.Substring(query + 1).Split('&'))
|
||||
{
|
||||
var equals = pair.IndexOf('=');
|
||||
if (equals <= 0 || equals + 1 >= pair.Length) continue;
|
||||
if (pair.Substring(0, equals).Equals("access_token", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string EnsureHubToken(string url, string token)
|
||||
{
|
||||
if (!IsHubUrl(url)) return url;
|
||||
if (HasHubToken(url)) return url;
|
||||
if (string.IsNullOrWhiteSpace(token)) return url;
|
||||
|
||||
var raw = token.Trim();
|
||||
if (raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) raw = raw.Substring(7).Trim();
|
||||
else if (raw.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) raw = string.Empty;
|
||||
if (raw.Length == 0) return url;
|
||||
|
||||
|
||||
|
||||
var query = url.IndexOf('?');
|
||||
var basePart = query < 0 ? url : url.Substring(0, query);
|
||||
var rest = query < 0 ? string.Empty : url.Substring(query + 1).TrimStart('&');
|
||||
var accessToken = "access_token=" + Uri.EscapeDataString(raw);
|
||||
|
||||
return basePart + "?" + accessToken + (rest.Length == 0 ? string.Empty : "&" + rest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using HarmonyLib;
|
||||
|
||||
internal static class RealtimeDiagnostics
|
||||
{
|
||||
private static readonly object Gate = new object();
|
||||
private static readonly Dictionary<string, int> Targets = new Dictionary<string, int>();
|
||||
private static readonly Dictionary<string, int> NotificationIds = new Dictionary<string, int>();
|
||||
private static readonly HashSet<string> KnownNotifications = new HashSet<string>();
|
||||
|
||||
private static readonly HashSet<string> HighFrequencyIds = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"PresenceUpdate",
|
||||
"AccountUpdate",
|
||||
"SelfAccountUpdate",
|
||||
"SubscriptionUpdatePresence",
|
||||
"SubscriptionUpdateProfile",
|
||||
"SubscriptionUpdateGameSession",
|
||||
"SubscriptionUpdateRoom",
|
||||
"SubscriptionUpdateRoomPlaylist"
|
||||
};
|
||||
|
||||
private static int Frames;
|
||||
private static int Failures;
|
||||
|
||||
private const int VerboseFrames = 40;
|
||||
|
||||
private const int PreviewChars = 400;
|
||||
|
||||
internal static void StartHandlerKeyProbe(Harmony harmony)
|
||||
{
|
||||
if (Interlocked.Exchange(ref HandlerKeyProbeStarted, 1) != 0) return;
|
||||
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
Type dispatcher = null;
|
||||
bool hooksInstalled = false;
|
||||
bool tablesDumped = false;
|
||||
|
||||
for (int attempt = 1; attempt <= HandlerKeyProbeAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (dispatcher == null) dispatcher = FindNotificationDispatcher();
|
||||
|
||||
if (!hooksInstalled && dispatcher != null)
|
||||
hooksInstalled = InstallRegistrationHooks(harmony, dispatcher);
|
||||
|
||||
if (!tablesDumped && attempt <= TableDumpAttempts)
|
||||
tablesDumped = DumpDispatcherKeys(attempt, dispatcher);
|
||||
|
||||
if (hooksInstalled && (tablesDumped || attempt >= TableDumpAttempts)) return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (attempt == 1)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Realtime] handler-key probe failed ({ex.GetType().Name}: {ex.Message}); retrying.");
|
||||
}
|
||||
|
||||
Thread.Sleep(HandlerKeyProbeIntervalMs);
|
||||
}
|
||||
|
||||
if (!hooksInstalled)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Realtime] no handler-registration method was located after {HandlerKeyProbeAttempts} attempts " +
|
||||
"- notification ids cannot be checked against the client's own registrations in this build.");
|
||||
})
|
||||
{ IsBackground = true, Name = "RadicadeNotifyKeyProbe" };
|
||||
|
||||
worker.Start();
|
||||
}
|
||||
|
||||
private const int HandlerKeyProbeAttempts = 60;
|
||||
private const int HandlerKeyProbeIntervalMs = 2000;
|
||||
private const int TableDumpAttempts = 3;
|
||||
private static int HandlerKeyProbeStarted;
|
||||
|
||||
private static readonly HashSet<string> RegisteredHandlerKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
private static bool ChatKeyReported;
|
||||
|
||||
private static bool InstallRegistrationHooks(Harmony harmony, Type dispatcher)
|
||||
{
|
||||
var hooked = 0;
|
||||
var callbacks = 0;
|
||||
var generics = 0;
|
||||
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(dispatcher))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (method.ReturnType != typeof(void)) continue;
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length != 1 && parameters.Length != 2) continue;
|
||||
|
||||
var keyType = parameters[0].ParameterType;
|
||||
if (keyType != typeof(string) && !keyType.IsEnum) continue;
|
||||
|
||||
if (parameters.Length == 2)
|
||||
{
|
||||
if (!IsCallback(parameters[1].ParameterType)) continue;
|
||||
callbacks++;
|
||||
|
||||
|
||||
|
||||
|
||||
if (method.IsGenericMethodDefinition) { generics++; continue; }
|
||||
}
|
||||
|
||||
harmony.Patch(method,
|
||||
prefix: new HarmonyMethod(AccessTools.Method(typeof(RealtimeDiagnostics), nameof(RegistrationPrefix))));
|
||||
hooked++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Realtime] could not hook {dispatcher.Name}.{method.Name} for key logging ({ex.GetType().Name}: {ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
if (hooked == 0) return false;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] watching {hooked} dispatcher method(s) on {dispatcher.FullName} " +
|
||||
$"({callbacks} registration, {generics} generic overload(s) skipped but funnel through the core, rest channels): " +
|
||||
"every notification id this client accepts will be printed as it registers.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsCallback(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (typeof(Delegate).IsAssignableFrom(type)) return true;
|
||||
|
||||
for (var current = type; current != null; current = current.BaseType)
|
||||
{
|
||||
var name = current.Name;
|
||||
if (name == "Delegate" || name == "MulticastDelegate") return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void RegistrationPrefix(object[] __args, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__args == null || __args.Length == 0) return;
|
||||
|
||||
var key = DescribeKey(__args[0]);
|
||||
if (key == null) return;
|
||||
|
||||
if (__args.Length == 1)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] the client called {__originalMethod?.Name}({key}) - " +
|
||||
"a realtime channel/lookup name it expects the server to publish on.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool first;
|
||||
lock (Gate) first = RegisteredHandlerKeys.Add(key);
|
||||
if (!first) return;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] the client registered a notification handler under {key} " +
|
||||
$"({__originalMethod?.DeclaringType?.Name}.{__originalMethod?.Name}) - " +
|
||||
$"a notification whose Id EQUALS {key} is handled, anything else is dropped.");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (key.IndexOf("Progression", StringComparison.Ordinal) >= 0
|
||||
|| key.IndexOf("AppVersion", StringComparison.Ordinal) >= 0
|
||||
|| key.IndexOf("Announcement", StringComparison.Ordinal) >= 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] {key} binds a payload of type {DescribeCallback(__args.Length > 1 ? __args[1] : null)}");
|
||||
|
||||
ReportChatKeyMatch();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string DescribeCallback(object? callback)
|
||||
{
|
||||
if (callback == null) return "(no callback)";
|
||||
|
||||
try
|
||||
{
|
||||
var type = callback.GetType();
|
||||
var candidates = new List<Type>();
|
||||
|
||||
if (type.IsGenericType) candidates.AddRange(type.GetGenericArguments());
|
||||
|
||||
for (var current = type; current != null; current = current.BaseType)
|
||||
{
|
||||
MethodInfo? invoke = null;
|
||||
try { invoke = current.GetMethod("Invoke", BindingFlags.Public | BindingFlags.Instance); }
|
||||
catch { }
|
||||
if (invoke == null) continue;
|
||||
|
||||
foreach (var parameter in invoke.GetParameters()) candidates.Add(parameter.ParameterType);
|
||||
}
|
||||
|
||||
var payloads = candidates
|
||||
.Where(t => t != null && t != typeof(void))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (payloads.Count == 0) return $"{type.FullName} (no payload readable)";
|
||||
|
||||
return string.Join(" | ", payloads.Select(DescribePayloadType));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"(unreadable: {ex.GetType().Name})";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribePayloadType(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (type.IsPrimitive || type == typeof(string)) return type.Name;
|
||||
|
||||
var members = new List<string>();
|
||||
|
||||
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
|
||||
members.Add(field.Name);
|
||||
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
members.Add(property.Name);
|
||||
|
||||
members = members.Distinct().OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||
if (members.Count == 0) return type.FullName ?? type.Name;
|
||||
|
||||
return $"{type.FullName} {{{string.Join(", ", members)}}}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return type.FullName ?? type.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeKey(object value)
|
||||
{
|
||||
if (value is string text) return Quote(text);
|
||||
if (value == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var type = value.GetType();
|
||||
return type.IsEnum ? Quote($"{type.Name}.{value}") : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReportChatKeyMatch()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (ChatKeyReported) return;
|
||||
|
||||
var numeric = RegisteredHandlerKeys.Contains("\"90\"");
|
||||
var named = RegisteredHandlerKeys.FirstOrDefault(
|
||||
key => key.IndexOf("ChatMessage", StringComparison.Ordinal) >= 0);
|
||||
|
||||
if (!numeric && named == null) return;
|
||||
ChatKeyReported = true;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] chat push key check: this client registered " +
|
||||
$"{(numeric ? "a handler for the chat enum's number (\"90\")" : "NO handler for the chat enum's number (\"90\")")}" +
|
||||
$"{(named == null ? string.Empty : $", and {named} by name")}. " +
|
||||
"Only an exact match is delivered, so the server's notification Id has to be the " +
|
||||
"spelling that was registered - for chat that is the name.");
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaxTables = 25;
|
||||
private const int MaxKeysPerTable = 60;
|
||||
|
||||
private static bool DumpDispatcherKeys(int attempt, Type dispatcher)
|
||||
{
|
||||
var tables = new List<string>();
|
||||
if (dispatcher != null) CollectStringKeyedTables(dispatcher, tables);
|
||||
|
||||
if (tables.Count == 0)
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
var name = assembly.GetName().Name ?? string.Empty;
|
||||
if (!name.StartsWith("RecNet", StringComparison.Ordinal)) continue;
|
||||
|
||||
Type[] types;
|
||||
try { types = assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray(); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
try { CollectStringKeyedTables(type, tables); } catch { }
|
||||
if (tables.Count >= MaxTables) break;
|
||||
}
|
||||
|
||||
if (tables.Count >= MaxTables) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.Count == 0) return false;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
dispatcher != null
|
||||
? $"[Realtime] the client's notification dispatcher ({dispatcher.FullName}) holds these string keys (attempt {attempt}). " +
|
||||
"A notification Id must EQUAL one of them to be handled - this is the contract:"
|
||||
: $"[Realtime] could not pin the notification dispatcher, so every string-keyed static table in RecNet.* is listed " +
|
||||
$"instead (attempt {attempt}). The handler keys are among them; a notification Id must EQUAL one to be handled:");
|
||||
|
||||
foreach (var table in tables)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {table}");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var flat = " " + string.Join(" ", tables) + " ";
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] chat push key check: the client registered {Registered(flat, "\"90\"")} for \"90\" and " +
|
||||
$"{Registered(flat, "ChatMessageReceived")} for \"ChatMessageReceived\". " +
|
||||
"Whichever is registered is the string the server's chat notification Id must equal.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Registered(string flatKeys, string key)
|
||||
=> flatKeys.Contains(" " + key, StringComparison.Ordinal) ? "a handler" : "NO handler";
|
||||
|
||||
private static void CollectStringKeyedTables(Type type, List<string> tables)
|
||||
{
|
||||
foreach (var field in type.GetFields(
|
||||
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (tables.Count >= MaxTables) return;
|
||||
|
||||
Type fieldType;
|
||||
try { fieldType = field.FieldType; } catch { continue; }
|
||||
if (!IsStringKeyed(fieldType)) continue;
|
||||
|
||||
object value;
|
||||
try { value = field.GetValue(null); }
|
||||
catch { continue; }
|
||||
|
||||
AddTable(tables, $"{type.Name}.{field.Name}", value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
private static bool IsStringKeyed(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
return type.IsGenericType && type.GetGenericArguments()[0] == typeof(string);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddTable(List<string> tables, string label, object value)
|
||||
{
|
||||
if (value == null) return;
|
||||
|
||||
var keys = new List<string>();
|
||||
try
|
||||
{
|
||||
if (value is System.Collections.IDictionary map)
|
||||
{
|
||||
foreach (var key in map.Keys) keys.Add(Quote(key?.ToString()));
|
||||
}
|
||||
else if (value is System.Collections.IEnumerable items)
|
||||
{
|
||||
foreach (var item in items) keys.Add(KeyOf(item));
|
||||
}
|
||||
else return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (keys.Count == 0) return;
|
||||
keys.Sort(StringComparer.Ordinal);
|
||||
|
||||
var shown = keys.Count <= MaxKeysPerTable
|
||||
? string.Join(", ", keys)
|
||||
: string.Join(", ", keys.Take(MaxKeysPerTable)) + $", … (+{keys.Count - MaxKeysPerTable} more)";
|
||||
|
||||
tables.Add($"{label}: {shown}");
|
||||
}
|
||||
|
||||
private static string KeyOf(object item)
|
||||
{
|
||||
if (item == null) return "(null)";
|
||||
|
||||
try
|
||||
{
|
||||
var type = item.GetType();
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>))
|
||||
{
|
||||
var key = type.GetProperty("Key");
|
||||
if (key != null) return Quote(key.GetValue(item)?.ToString());
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return Quote(item.ToString());
|
||||
}
|
||||
|
||||
private static string Quote(string text) => text == null ? "(null)" : $"\"{text}\"";
|
||||
|
||||
private static Type FindNotificationDispatcher()
|
||||
{
|
||||
|
||||
|
||||
var byName = FindType("LMBGGGGNBOB");
|
||||
if (byName != null && DeclaresNotificationEnum(byName)) return byName;
|
||||
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type[] types;
|
||||
try { types = assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray(); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!type.IsEnum || !IsNotificationEnum(type)) continue;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] notification enum located: {type.FullName} " +
|
||||
$"(message = {Convert.ToInt64(Enum.Parse(type, "MessageReceived"), System.Globalization.CultureInfo.InvariantCulture)}, " +
|
||||
$"kick = {Convert.ToInt64(Enum.Parse(type, "ModerationKick"), System.Globalization.CultureInfo.InvariantCulture)})");
|
||||
|
||||
|
||||
|
||||
|
||||
if (type.DeclaringType != null) return type.DeclaringType;
|
||||
continue;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool DeclaresNotificationEnum(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
|
||||
if (nested.IsEnum && IsNotificationEnum(nested)) return true;
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNotificationEnum(Type type)
|
||||
{
|
||||
var members = Enum.GetNames(type);
|
||||
return Array.IndexOf(members, "MessageReceived") >= 0
|
||||
&& Array.IndexOf(members, "ModerationKick") >= 0
|
||||
&& Array.IndexOf(members, "PresenceHeartbeatResponse") >= 0;
|
||||
}
|
||||
|
||||
internal static System.DateTime? ServerRequestedExitUtc;
|
||||
|
||||
private const int ExitRequestWindowSeconds = 120;
|
||||
|
||||
internal static bool ServerRequestedExitRecently()
|
||||
=> ServerRequestedExitUtc.HasValue
|
||||
&& (System.DateTime.UtcNow - ServerRequestedExitUtc.Value).TotalSeconds < ExitRequestWindowSeconds;
|
||||
|
||||
private static void NoteServerRequestedExit(string id)
|
||||
{
|
||||
if (id != "20" && id != "6") return;
|
||||
if (ServerRequestedExitRecently()) return;
|
||||
|
||||
ServerRequestedExitUtc = System.DateTime.UtcNow;
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[Realtime] Id={id} arrived - the server asked this client to leave, so the AppExit guard will let the exit through.");
|
||||
}
|
||||
|
||||
internal static void ApplyExitWatch(Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transport = FindType("BestHTTP.SignalRCore.Transports.WebSocketTransport");
|
||||
var target = transport == null ? null : AccessTools.DeclaredMethod(transport, "OnMessage");
|
||||
if (target == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[Realtime] could not watch for the server's quit/logout frames - a moderator's Quit Game may be suppressed as a room-load exit.");
|
||||
return;
|
||||
}
|
||||
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(typeof(RealtimeDiagnostics), nameof(ExitWatchPrefix)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] exit watch failed to install: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExitWatchPrefix(string __1)
|
||||
{
|
||||
var text = __1;
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
|
||||
|
||||
|
||||
|
||||
if (text.IndexOf("\"Notification\"", StringComparison.Ordinal) < 0) return;
|
||||
if (text.IndexOf("\"Id\":\"20\"", StringComparison.Ordinal) >= 0 || text.IndexOf("\"Id\":20", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
NoteServerRequestedExit("20");
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.IndexOf("\"Id\":\"6\"", StringComparison.Ordinal) >= 0 || text.IndexOf("\"Id\":6", StringComparison.Ordinal) >= 0)
|
||||
NoteServerRequestedExit("6");
|
||||
}
|
||||
|
||||
internal static bool Verbose = true;
|
||||
|
||||
internal static void Apply(Harmony harmony, bool verbose)
|
||||
{
|
||||
Verbose = verbose;
|
||||
var transport = FindType("BestHTTP.SignalRCore.Transports.WebSocketTransport");
|
||||
if (transport == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] BestHTTP.SignalRCore.Transports.WebSocketTransport not found - notify frame logging stays inactive.");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
|
||||
|
||||
Hook(harmony, transport, "OnOpen", nameof(OnOpenPrefix), "socket open");
|
||||
Hook(harmony, transport, "HandleHandshakeResponse", nameof(HandshakePrefix), "SignalR handshake answer");
|
||||
Hook(harmony, transport, "OnMessage", nameof(OnMessagePrefix), "inbound frames");
|
||||
Hook(harmony, transport, "OnError", nameof(OnErrorPrefix), "transport errors");
|
||||
Hook(harmony, transport, "OnClosed", nameof(OnClosedPrefix), "socket close with code/reason");
|
||||
}
|
||||
|
||||
var hub = FindType("BestHTTP.SignalRCore.HubConnection");
|
||||
if (hub == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] BestHTTP.SignalRCore.HubConnection not found - hub-pump guard stays inactive.");
|
||||
}
|
||||
else
|
||||
{
|
||||
HookFinalizer(harmony, hub, "OnMessages", nameof(OnMessagesFinalizer));
|
||||
}
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(Verbose
|
||||
? "[Realtime] notify-hub tap armed: every inbound frame is read and logged."
|
||||
: "[Realtime] notify-hub tap armed for moderation only - a voice chat restriction is applied from the frame it arrives in; frames are not logged (Diagnostics=false).");
|
||||
|
||||
|
||||
|
||||
|
||||
if (Verbose) StartHandlerKeyProbe(harmony);
|
||||
}
|
||||
|
||||
private static Type FindType(string name)
|
||||
{
|
||||
|
||||
|
||||
|
||||
var direct = AccessTools.TypeByName(name);
|
||||
if (direct != null) return direct;
|
||||
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
var found = assembly.GetType(name, false);
|
||||
if (found != null) return found;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Hook(Harmony harmony, Type type, string method, string prefix, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = AccessTools.DeclaredMethod(type, method);
|
||||
if (target == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] {type.Name}.{method} not found - {what} will not be logged.");
|
||||
return;
|
||||
}
|
||||
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(typeof(RealtimeDiagnostics), prefix));
|
||||
if (Verbose)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] hooked {type.Name}.{method} ({what})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] could not hook {type.Name}.{method}: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void HookFinalizer(Harmony harmony, Type type, string method, string finalizer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = AccessTools.DeclaredMethod(type, method);
|
||||
if (target == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] {type.Name}.{method} not found - the hub-pump guard stays inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
harmony.Patch(target, finalizer: new HarmonyMethod(typeof(RealtimeDiagnostics), finalizer));
|
||||
if (Verbose)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] guarded {type.Name}.{method} (a frame the client cannot parse is logged, not thrown)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] could not guard {type.Name}.{method}: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static void OnOpenPrefix()
|
||||
{
|
||||
if (Verbose)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] socket open - the notify hub WebSocket upgraded and is ready.");
|
||||
}
|
||||
|
||||
private static void HandshakePrefix(string __0)
|
||||
{
|
||||
|
||||
|
||||
var text = __0 ?? string.Empty;
|
||||
if (text.IndexOf("error", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] hub handshake answered with an error: {Trim(text, 300)}");
|
||||
else if (Verbose)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] hub handshake accepted: {Trim(text, 120)}");
|
||||
}
|
||||
|
||||
private static void OnMessagePrefix(string __1) => ObserveFrame(__1);
|
||||
|
||||
private static void OnErrorPrefix(string __1) =>
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] transport error: {Trim(__1, 300)}");
|
||||
|
||||
private static void OnClosedPrefix(ushort __1, string __2) =>
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] socket closed code={__1} reason={Trim(__2, 200)}");
|
||||
|
||||
private static Exception OnMessagesFinalizer(Exception __exception)
|
||||
{
|
||||
if (__exception == null) return null;
|
||||
|
||||
lock (Gate) Failures++;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Realtime] the client failed to handle a hub frame ({__exception.GetType().Name}: {__exception.Message}) - " +
|
||||
$"frame swallowed so the notify socket and the rest of the batch survive (failures={Failures}). " +
|
||||
"A parse error here means a notification payload the client's own parser rejects; the IdToken/summary lines " +
|
||||
"above name which notification it was.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static void ObserveFrame(string data)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data))
|
||||
{
|
||||
if (Verbose)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] empty frame received (keep-alive).");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
foreach (var record in data.Split('\u001e'))
|
||||
{
|
||||
if (record.Length == 0) continue;
|
||||
|
||||
|
||||
|
||||
|
||||
if (!Verbose && record.IndexOf(VoiceChatRestrictionPatches.NotificationId, StringComparison.Ordinal) < 0)
|
||||
continue;
|
||||
|
||||
int index;
|
||||
lock (Gate) index = ++Frames;
|
||||
|
||||
DescribeRecord(index, record);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DescribeRecord(int index, string record)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(record);
|
||||
var root = document.RootElement;
|
||||
|
||||
var type = root.TryGetProperty("type", out var typeElement) ? typeElement.GetRawText() : "?";
|
||||
var target = root.TryGetProperty("target", out var targetElement) ? targetElement.GetString() : null;
|
||||
|
||||
if (string.Equals(target, "Notification", StringComparison.Ordinal))
|
||||
{
|
||||
DescribeNotification(index, root, record);
|
||||
return;
|
||||
}
|
||||
|
||||
Count(Targets, target ?? ("type " + type));
|
||||
|
||||
|
||||
|
||||
if (!Verbose) return;
|
||||
if (index <= VerboseFrames)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] frame #{index} target={target ?? "-"} type={type} len={record.Length}: {Trim(record, PreviewChars)}");
|
||||
else
|
||||
Summarize(index);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] frame #{index} is not readable as a hub record ({ex.GetType().Name}: {ex.Message}): {Trim(record, PreviewChars)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DescribeNotification(int index, JsonElement root, string record)
|
||||
{
|
||||
string envelope = null;
|
||||
if (root.TryGetProperty("arguments", out var arguments)
|
||||
&& arguments.ValueKind == JsonValueKind.Array
|
||||
&& arguments.GetArrayLength() > 0)
|
||||
{
|
||||
var first = arguments[0];
|
||||
envelope = first.ValueKind == JsonValueKind.String ? first.GetString() : first.GetRawText();
|
||||
}
|
||||
|
||||
string id = null;
|
||||
string idToken = "(no Id)";
|
||||
string msg = "(no Msg)";
|
||||
|
||||
if (!string.IsNullOrEmpty(envelope))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var inner = JsonDocument.Parse(envelope);
|
||||
var payload = inner.RootElement;
|
||||
|
||||
|
||||
|
||||
|
||||
if (TryGetProperty(payload, "Id", out var idElement))
|
||||
{
|
||||
idToken = idElement.ValueKind.ToString();
|
||||
id = idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : idElement.GetRawText();
|
||||
}
|
||||
|
||||
if (TryGetProperty(payload, "Msg", out var msgElement))
|
||||
msg = Trim(msgElement.GetRawText(), 200);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] Notification #{index} envelope did not parse at all ({ex.GetType().Name}: {ex.Message}): {Trim(envelope, PreviewChars)}");
|
||||
envelope = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(id)) NoteServerRequestedExit(id);
|
||||
|
||||
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(envelope))
|
||||
VoiceChatRestrictionPatches.Observe(id, envelope);
|
||||
|
||||
Count(Targets, "Notification");
|
||||
Count(NotificationIds, id ?? "(no Id)");
|
||||
|
||||
bool firstOfItsKind = false;
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
lock (Gate) firstOfItsKind = KnownNotifications.Add(id);
|
||||
|
||||
|
||||
if (!Verbose) return;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
bool worthFullDetail = string.IsNullOrEmpty(id) || !HighFrequencyIds.Contains(id);
|
||||
|
||||
if (worthFullDetail || firstOfItsKind || index <= VerboseFrames)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] Notification #{index} Id={id ?? "(none)"} IdToken={idToken} Msg={msg}");
|
||||
else
|
||||
Summarize(index);
|
||||
|
||||
if (worthFullDetail || firstOfItsKind)
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {id ?? "envelope"} envelope: {Trim(envelope ?? record, PreviewChars)}");
|
||||
}
|
||||
|
||||
private static void Summarize(int index)
|
||||
{
|
||||
if (index % 25 != 0) return;
|
||||
|
||||
string targets;
|
||||
string ids;
|
||||
int failures;
|
||||
lock (Gate)
|
||||
{
|
||||
targets = Join(Targets);
|
||||
ids = Join(NotificationIds);
|
||||
failures = Failures;
|
||||
} RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {index} frames so far - targets: {targets}; notifications: {ids}; handler failures: {failures}");
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement element, string name, out JsonElement value)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (element.TryGetProperty(name, out value)) return true;
|
||||
|
||||
|
||||
|
||||
var lower = char.ToLowerInvariant(name[0]) + name.Substring(1);
|
||||
if (element.TryGetProperty(lower, out value)) return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Count(Dictionary<string, int> counts, string key)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
counts.TryGetValue(key, out var current);
|
||||
counts[key] = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Join(Dictionary<string, int> counts)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var pair in counts) parts.Add($"{pair.Key} x{pair.Value}");
|
||||
return parts.Count == 0 ? "none" : string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string Trim(string text, int limit)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return string.Empty;
|
||||
var flat = text.Replace("\r", " ").Replace("\n", " ");
|
||||
return flat.Length <= limit ? flat : flat.Substring(0, limit) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BestHTTP;
|
||||
using HarmonyLib;
|
||||
|
||||
internal static class RequestRouteLog
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<string> Seen = new();
|
||||
|
||||
private const int Limit = 300;
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = AccessTools.TypeByName("BestHTTP.HTTPResponse");
|
||||
if (response == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[HttpLog] BestHTTP.HTTPResponse not found - REST routes stay unlogged.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
System.Reflection.ConstructorInfo constructor = null;
|
||||
foreach (var candidate in AccessTools.GetDeclaredConstructors(response))
|
||||
{
|
||||
var parameters = candidate.GetParameters();
|
||||
if (parameters.Length == 4
|
||||
&& parameters[0].ParameterType.Name.Contains("HTTPRequest")
|
||||
&& parameters[2].ParameterType.Name == "Boolean"
|
||||
&& parameters[3].ParameterType.Name == "Boolean")
|
||||
{
|
||||
constructor = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (constructor == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[HttpLog] HTTPResponse ctor not found - REST routes stay unlogged.");
|
||||
return;
|
||||
}
|
||||
|
||||
harmony.Patch(constructor, postfix: new HarmonyMethod(typeof(RequestRouteLog), nameof(ResponsePostfix)));
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[HttpLog] hooked HTTPResponse(HTTPRequest, Stream) - every REST route and status is logged once");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[HttpLog] could not hook HTTPResponse: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResponsePostfix(HTTPResponse __instance, HTTPRequest __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = __0?.Uri?.ToString();
|
||||
if (string.IsNullOrEmpty(uri)) return;
|
||||
|
||||
if (!HubSocketAuth.IsServer(uri)) return;
|
||||
|
||||
var path = new Uri(uri).AbsolutePath;
|
||||
|
||||
|
||||
|
||||
if (NameserverRedirect.IsHubUrl(uri)) return;
|
||||
if (path.Contains("/images/", StringComparison.OrdinalIgnoreCase)) return;
|
||||
if (path.Contains("/cdn/", StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
int status;
|
||||
try { status = __instance.StatusCode; }
|
||||
catch { return; }
|
||||
|
||||
var method = "REQ";
|
||||
try { method = __0.MethodType.ToString().ToUpperInvariant(); }
|
||||
catch { }
|
||||
|
||||
var line = $"{status} {method} {path}";
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (Seen.Count >= Limit || !Seen.Add(line)) return;
|
||||
}
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo($"[HttpLog] {line}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
/// <summary>
|
||||
/// Pure string surgery for two responses the client cannot cope with as the
|
||||
/// server sends them. No engine or BestHTTP types live here so the rules can be
|
||||
/// exercised outside the game.
|
||||
/// </summary>
|
||||
internal static class ResponseRepair
|
||||
{
|
||||
/// <summary>Loopback probe target: any transport fails (or answers) instantly, so a probe phase can never stall.</summary>
|
||||
internal const string LoopbackProbeAddress = "127.0.0.1:50000";
|
||||
|
||||
private const string QosPathSuffix = "/matchmaking/player/qos";
|
||||
private const string UserSegmentPathSuffix = "/data/commonquery/usersegment";
|
||||
private const string AddressKey = "\"address\"";
|
||||
|
||||
/// <summary>
|
||||
/// BestHTTP's chunked reader throws exactly this when the chunk-size line is
|
||||
/// empty, which is what a keep-alive connection the server had already closed
|
||||
/// looks like. It is not a bad request, so it must not fail one.
|
||||
/// </summary>
|
||||
internal static bool IsTruncatedChunkFailure(string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return false;
|
||||
return message.IndexOf("as a hex number", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites a response body when the client would otherwise mis-handle it.
|
||||
/// Returns null when the body is left alone; <paramref name="note"/> explains
|
||||
/// what changed when it did.
|
||||
/// </summary>
|
||||
internal static string RepairBody(string path, string query, string body, out string note)
|
||||
{
|
||||
note = null;
|
||||
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(body)) return null;
|
||||
|
||||
if (EndsWith(path, QosPathSuffix)) return RepairProbeList(body, ref note);
|
||||
if (EndsWith(path, UserSegmentPathSuffix)) return RepairUserSegment(query, body, ref note);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The probe list points at retired GCP addresses: five of its six entries are
|
||||
/// unreachable, so the client's region-probe phase waits out a ping timeout on
|
||||
/// each boot and login sits there. Pinning every entry at loopback keeps the
|
||||
/// list's shape but makes the probes answer (or refuse) immediately.
|
||||
/// </summary>
|
||||
private static string RepairProbeList(string body, ref string note)
|
||||
{
|
||||
var repaired = new StringBuilder(body.Length);
|
||||
var index = 0;
|
||||
var replaced = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var key = body.IndexOf(AddressKey, index, StringComparison.Ordinal);
|
||||
if (key < 0)
|
||||
{
|
||||
repaired.Append(body, index, body.Length - index);
|
||||
break;
|
||||
}
|
||||
|
||||
repaired.Append(body, index, key - index + AddressKey.Length);
|
||||
|
||||
var cursor = key + AddressKey.Length;
|
||||
while (cursor < body.Length && char.IsWhiteSpace(body[cursor])) cursor++;
|
||||
if (cursor >= body.Length || body[cursor] != ':')
|
||||
{
|
||||
repaired.Append(body, key + AddressKey.Length, cursor - key - AddressKey.Length);
|
||||
index = cursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
repaired.Append(body, key + AddressKey.Length, cursor - key - AddressKey.Length);
|
||||
repaired.Append(':');
|
||||
cursor++;
|
||||
while (cursor < body.Length && char.IsWhiteSpace(body[cursor])) cursor++;
|
||||
if (cursor >= body.Length || body[cursor] != '"')
|
||||
{
|
||||
index = cursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
var close = body.IndexOf('"', cursor + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
repaired.Append(body, cursor, body.Length - cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
repaired.Append('"').Append(LoopbackProbeAddress).Append('"');
|
||||
replaced++;
|
||||
index = close + 1;
|
||||
}
|
||||
|
||||
if (replaced == 0) return null;
|
||||
|
||||
note = replaced.ToString(CultureInfo.InvariantCulture) + " region probe address(es) pinned to "
|
||||
+ LoopbackProbeAddress + " (the server's list points at retired hosts)";
|
||||
return repaired.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client's model for this route is a name -> row map
|
||||
/// (Dictionary<string, ...>), so the flat member object the server answers
|
||||
/// with makes its reader demand '{' where a string sits and throw
|
||||
/// "expected:'{', actual:'\"none\"'" (a crash report per login). Wrapping the
|
||||
/// flat object under the row key(s) the request named gives it the map it wants.
|
||||
/// </summary>
|
||||
private static string RepairUserSegment(string query, string body, ref string note)
|
||||
{
|
||||
var trimmed = body.TrimStart();
|
||||
if (trimmed.Length == 0 || trimmed[0] != '{') return null;
|
||||
|
||||
var keys = new List<string>();
|
||||
foreach (var value in QueryValues(query))
|
||||
{
|
||||
AddKey(keys, value);
|
||||
AddKey(keys, "user_segment:" + value);
|
||||
}
|
||||
if (keys.Count == 0) AddKey(keys, "user_segment");
|
||||
|
||||
var repaired = new StringBuilder(body.Length * (keys.Count + 1) + 16);
|
||||
repaired.Append('{');
|
||||
for (var i = 0; i < keys.Count; i++)
|
||||
{
|
||||
if (i > 0) repaired.Append(',');
|
||||
repaired.Append('"').Append(keys[i]).Append("\":").Append(body);
|
||||
}
|
||||
repaired.Append('}');
|
||||
|
||||
note = "userSegment answered as a name -> row map (" + string.Join(", ", keys) + ")";
|
||||
return repaired.ToString();
|
||||
}
|
||||
|
||||
private static void AddKey(List<string> keys, string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return;
|
||||
foreach (var existing in keys)
|
||||
if (existing == key) return;
|
||||
keys.Add(key);
|
||||
}
|
||||
|
||||
internal static IEnumerable<string> QueryValues(string query)
|
||||
{
|
||||
if (string.IsNullOrEmpty(query)) yield break;
|
||||
foreach (var pair in query.TrimStart('?').Split('&'))
|
||||
{
|
||||
if (pair.Length == 0) continue;
|
||||
var separator = pair.IndexOf('=');
|
||||
var value = separator < 0 ? pair : pair.Substring(separator + 1);
|
||||
value = Uri.UnescapeDataString(value).Trim();
|
||||
if (value.Length == 0) continue;
|
||||
yield return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool EndsWith(string value, string suffix) =>
|
||||
value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using HarmonyLib;
|
||||
|
||||
internal static class WebSocketRedirectPatches
|
||||
{
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
var type = AccessTools.TypeByName("BestHTTP.WebSocket.WebSocket");
|
||||
int count = 0;
|
||||
if (type != null)
|
||||
foreach (var constructor in AccessTools.GetDeclaredConstructors(type))
|
||||
{
|
||||
var parameters = constructor.GetParameters();
|
||||
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(Il2CppSystem.Uri)) continue;
|
||||
harmony.Patch(constructor, prefix: new HarmonyMethod(typeof(WebSocketRedirectPatches), nameof(Prefix)));
|
||||
count++;
|
||||
}
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] WebSocket URI hooks installed: " + count);
|
||||
}
|
||||
private static void Prefix(ref Il2CppSystem.Uri __0)
|
||||
{
|
||||
if (__0 == null) return;
|
||||
var url = NameserverRedirect.RewriteWebSocket(__0.ToString(), RadicadeEacBypassPlugin.ServerUrl.Value);
|
||||
if (HubSocketAuth.IsServer(url) && NameserverRedirect.IsHubUrl(url))
|
||||
{
|
||||
url = NameserverRedirect.EnsureHubToken(url, HubSocketAuth.Token);
|
||||
Diag.Trace("[Realtime] Hub socket constructed; token=" +
|
||||
(NameserverRedirect.HasHubToken(url) ? "present" : "missing"));
|
||||
}
|
||||
__0 = new Il2CppSystem.Uri(url);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user