Files
bd2/plugins/LoginUI/SecureGameRelay.cs
T

175 lines
9.1 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BepInEx.Logging;
namespace Bd2LoginUI;
// Unity keeps its game packet parser; only the HTTPS transport runs through the OS.
// This listener binds loopback only and can forward game paths to one fixed origin.
internal sealed class SecureGameRelay : IDisposable
{
private readonly Uri server;
private readonly Uri local;
private readonly string prefix;
private readonly TcpListener listener;
private readonly CancellationTokenSource lifetime = new CancellationTokenSource();
private readonly SemaphoreSlim slots = new SemaphoreSlim(16, 16);
private readonly ManualLogSource log;
private int disposed;
private SecureGameRelay(Uri server, ManualLogSource log)
{
if (server == null || server.Scheme != Uri.UriSchemeHttps || server.AbsolutePath != "/" ||
server.UserInfo.Length != 0 || server.Query.Length != 0 || server.Fragment.Length != 0)
throw new ArgumentException("Game relay requires a fixed HTTPS origin");
this.server = server;
this.log = log;
byte[] nonce = new byte[32];
using (RandomNumberGenerator random = RandomNumberGenerator.Create()) random.GetBytes(nonce);
prefix = "/" + BitConverter.ToString(nonce).Replace("-", "").ToLowerInvariant() + "/";
Array.Clear(nonce, 0, nonce.Length);
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
local = new Uri("http://127.0.0.1:" + ((IPEndPoint)listener.LocalEndpoint).Port.ToString(CultureInfo.InvariantCulture) + "/");
new Thread(AcceptLoop) { IsBackground = true, Name = "BD2 secure game transport" }.Start();
log?.LogInfo("Game HTTPS transport uses native OS networking through a private loopback relay");
}
internal static SecureGameRelay Start(Uri server, ManualLogSource log) => new SecureGameRelay(server, log);
internal Uri Rewrite(Uri uri)
{
return IsRelayedUri(uri) ? new Uri(local, prefix + uri.PathAndQuery.TrimStart('/')) : uri;
}
internal bool TryResolve(Uri uri, out Uri original)
{
original = null;
if (uri == null || uri.Scheme != local.Scheme || uri.Host != local.Host || uri.Port != local.Port ||
!uri.AbsolutePath.StartsWith(prefix, StringComparison.Ordinal)) return false;
Uri resolved = new Uri(server, "/" + uri.PathAndQuery.Substring(prefix.Length));
if (!IsRelayedUri(resolved)) return false;
original = resolved;
return true;
}
private bool IsRelayedUri(Uri uri) => uri != null && uri.Scheme == server.Scheme &&
uri.IdnHost == server.IdnHost && uri.Port == server.Port && uri.UserInfo.Length == 0 &&
uri.Fragment.Length == 0 && (uri.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal) ||
uri.AbsolutePath.Equals("/logs", StringComparison.Ordinal));
private void AcceptLoop()
{
while (Volatile.Read(ref disposed) == 0)
{
TcpClient client;
try { client = listener.AcceptTcpClient(); }
catch (Exception) { if (Volatile.Read(ref disposed) != 0) return; continue; }
if (!slots.Wait(0)) { client.Dispose(); continue; }
Task.Run(async () =>
{
try { using (client) await Serve(client).ConfigureAwait(false); }
catch (Exception ex) when (ex is IOException || ex is SocketException || ex is OperationCanceledException || ex is InvalidDataException)
{ log?.LogWarning("Native game relay connection failed: " + ex.GetType().Name); }
catch (Exception ex) { log?.LogWarning("Native game transport failed: " + ex.GetType().Name); }
finally { slots.Release(); }
});
}
}
private async Task Serve(TcpClient client)
{
client.ReceiveTimeout = 5000;
client.SendTimeout = 5000;
NetworkStream stream = client.GetStream();
byte[] header = new byte[16 * 1024];
int used = 0;
while (used < header.Length)
{
int next = stream.ReadByte();
if (next < 0) throw new IOException("Incomplete relay headers");
header[used++] = (byte)next;
if (used >= 4 && header[used - 4] == 13 && header[used - 3] == 10 && header[used - 2] == 13 && header[used - 1] == 10) break;
}
if (used == header.Length) throw new InvalidDataException("Relay header limit");
string[] lines = Encoding.ASCII.GetString(header, 0, used).Split(new[] { "\r\n" }, StringSplitOptions.None);
Array.Clear(header, 0, header.Length);
string[] start = lines[0].Split(' ');
if (start.Length != 3 || (start[0] != "PUT" && start[0] != "POST" && start[0] != "GET") ||
!start[1].StartsWith(prefix, StringComparison.Ordinal) || !start[2].StartsWith("HTTP/1.", StringComparison.Ordinal))
throw new InvalidDataException("Invalid relay request");
if (!Uri.TryCreate(local, start[1], out Uri target) || !TryResolve(target, out Uri remote))
throw new InvalidDataException("Invalid relay target");
bool maintenance = remote.AbsolutePath.Equals("/game/MaintenanceInfo", StringComparison.Ordinal);
if (maintenance) log?.LogInfo("Game relay received MaintenanceInfo request");
Dictionary<string, string> headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
int length = 0;
bool lengthSeen = false;
foreach (string line in lines)
{
int separator = line.IndexOf(':');
if (separator <= 0) continue;
string name = line.Substring(0, separator).Trim(), value = line.Substring(separator + 1).Trim();
if (name.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Chunked relay requests are not supported");
if (name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
{
if (lengthSeen || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out length) || length > 8 * 1024 * 1024)
throw new InvalidDataException("Invalid relay content length");
lengthSeen = true;
}
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
name.Equals("b2_app_version", StringComparison.OrdinalIgnoreCase) || name.Equals("b2_bundle_version", StringComparison.OrdinalIgnoreCase) ||
name.Equals("market_type", StringComparison.OrdinalIgnoreCase)) headers[name] = value;
}
byte[] body = length == 0 ? null : new byte[length];
PlatformControlHttp.Response response = null;
try
{
for (int offset = 0; offset < length;)
{
int count = stream.Read(body, offset, length - offset);
if (count <= 0) throw new IOException("Incomplete relay body");
offset += count;
}
response = await PlatformControlHttp.Send(remote, start[0], body, null, 4, lifetime.Token,
lifetime.Token, 64 * 1024 * 1024, headers).ConfigureAwait(false);
if (maintenance || response.StatusCode == 0)
log?.LogInfo("Game relay upstream result: path=" + remote.AbsolutePath + " status=" + response.StatusCode +
" error=" + (response.Error ?? "none"));
if (response.StatusCode == 0 || response.Data == null) throw new IOException("Native game transport unavailable");
StringBuilder output = new StringBuilder("HTTP/1.1 ").Append(response.StatusCode.ToString(CultureInfo.InvariantCulture)).Append(" Response\r\nConnection: close\r\nCache-Control: no-store\r\n");
foreach (KeyValuePair<string, string> item in response.Headers)
{
if ((item.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || item.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
item.Key.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase)) && item.Value.IndexOfAny(new[] { '\r', '\n' }) < 0)
output.Append(item.Key).Append(": ").Append(item.Value).Append("\r\n");
}
output.Append("Content-Length: ").Append(response.Data.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n\r\n");
byte[] replyHeader = Encoding.ASCII.GetBytes(output.ToString());
stream.Write(replyHeader, 0, replyHeader.Length);
stream.Write(response.Data, 0, response.Data.Length);
Array.Clear(replyHeader, 0, replyHeader.Length);
}
finally
{
if (body != null) Array.Clear(body, 0, body.Length);
if (response?.Data != null) Array.Clear(response.Data, 0, response.Data.Length);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
lifetime.Cancel();
listener.Stop();
}
}