Files
bd2/plugins/LocalIdentity/ClientRouting.cs
T

443 lines
18 KiB
C#

using System;
using System.Reflection;
using BD2.GameNames;
using HarmonyLib;
using System.IO;
using System.Net;
using System.Text;
using BepInEx;
using BepInEx.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Bd2LocalIdentity;
internal sealed class ClientRouting : IDisposable
{
private static readonly char[] InvalidOriginCharacters = ['?', '#', '@', '%'];
private static ClientRouting installedRouting;
private static ManualLogSource routingLog;
internal void Install(Harmony harmony)
{
installedRouting = this;
routingLog = log;
MethodInfo load = Game.Method(() => BDNetwork.ServerURLInfo.Load());
MethodInfo sendMaintenance = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
MethodInfo makeCDNInfo = Game.Method(() => BDNetwork.CommonPacket.MakeCDNInfo(null));
if (load == null || sendMaintenance == null || makeCDNInfo == null)
{
throw new MissingMethodException("2.35.10 server/resource routing methods were not found");
}
harmony.Patch(load, postfix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyServerOriginHook)));
harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyServerOriginHook)));
harmony.Patch(makeCDNInfo, postfix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyResourcesHook)));
routingLog?.LogInfo("Server origin and native CdnInfo routing patches installed");
}
private static void ApplyServerOriginHook()
{
ClientRouting routing = installedRouting;
if (routing == null)
{
routingLog?.LogError("Client routing is unavailable while applying the server origin");
return;
}
routing.ApplyServerOrigin();
}
private static void ApplyResourcesHook()
{
ClientRouting routing = installedRouting;
if (routing == null)
{
routingLog?.LogError("Client routing is unavailable while applying resources");
return;
}
routing.ApplyResources();
}
private const string ConfigFileName = "bd2.client.json";
private const string OfficialServerData = "https://bd2-cdn.akamaized.net/ServerData";
private const string OfficialGameData = "https://bd2-cdn.akamaized.net/GameData";
private const int MaximumResponseBytes = 16 * 1024;
private readonly ManualLogSource log;
private readonly ClientConfig config;
internal string ServerOrigin => config.server_origin;
private readonly ResourcePolicy resources;
private readonly LocalResourceServer localResourceServer;
private ClientRouting(
ManualLogSource log,
ClientConfig config,
ResourcePolicy resources,
LocalResourceServer localResourceServer)
{
this.log = log;
this.config = config;
this.resources = resources;
this.localResourceServer = localResourceServer;
}
internal static ClientRouting Load(ManualLogSource log)
{
string path = Path.Combine(Paths.ConfigPath, ConfigFileName);
ClientConfig config = ParseConfig(ReadBoundedFile(path, MaximumResponseBytes));
// Set only our process-local choice. Login UI also uses this value when
// the game is launched directly; it never imports a system proxy.
Environment.SetEnvironmentVariable("BD2_CLIENT_PROXY_URL", config.proxy_url);
ValidateOrigin(config.server_origin, "server_origin");
config.server_origin = config.server_origin.TrimEnd('/');
ResourcePolicy resources;
LocalResourceServer localResourceServer = null;
try
{
if (config.cdn_mode == "official")
{
resources = new ResourcePolicy
{
mode = "official",
server_data_url = OfficialServerData,
game_data_url = OfficialGameData,
bundle_version = Bd2Build.Versions.Bundle,
game_data_version = Bd2Build.Versions.GameData
};
log.LogInfo("Client resource mode official: direct official CDN with release-locked versions");
}
else if (config.cdn_mode == "local")
{
localResourceServer = LocalResourceServer.Start(
log,
config.local_resource_directory,
Bd2Build.Versions.Bundle,
Bd2Build.Versions.GameData);
resources = new ResourcePolicy
{
mode = "local",
server_data_url = localResourceServer.Origin + "/ServerData",
game_data_url = localResourceServer.Origin + "/GameData",
bundle_version = Bd2Build.Versions.Bundle,
game_data_version = Bd2Build.Versions.GameData
};
log.LogInfo("Client resource mode local: serving downloaded resources from " +
localResourceServer.RootDirectory);
}
else
{
resources = RequestResourcePolicy(log, config);
}
ValidatePolicy(config.cdn_mode, resources);
return new ClientRouting(log, config, resources, localResourceServer);
}
catch
{
localResourceServer?.Dispose();
throw;
}
}
public void Dispose()
{
if (ReferenceEquals(installedRouting, this)) installedRouting = null;
localResourceServer?.Dispose();
}
internal void ApplyServerOrigin()
{
var gameEndpoint = new Uri(new Uri(config.server_origin + "/"), "game/");
BDNetwork.ServerURLInfo.SetUriDirectly(gameEndpoint);
log.LogInfo("Client server origin applied: " + config.server_origin);
}
internal void ApplyResources()
{
BDNetwork.CdnInfo.Info = resources.server_data_url;
BDNetwork.CdnInfo.Version = resources.bundle_version;
BDNetwork.CdnInfo.SoundVersion = resources.bundle_version;
BDNetwork.CdnInfo.GameDataInfo = resources.game_data_url;
BDNetwork.CdnInfo.GameDataVersion = resources.game_data_version;
log.LogInfo("Client resources applied: mode=" + resources.mode +
" server_data=" + resources.server_data_url +
" game_data=" + resources.game_data_url +
" bundle=" + resources.bundle_version +
" game_data_version=" + resources.game_data_version);
}
private static ResourcePolicy RequestResourcePolicy(ManualLogSource log, ClientConfig config)
{
var endpoint = new Uri(new Uri(config.server_origin + "/"), "client/resources");
byte[] body = Encoding.UTF8.GetBytes("{\"cdn_mode\":\"" + config.cdn_mode + "\"}");
var request = (HttpWebRequest)WebRequest.Create(endpoint);
request.Method = "PUT";
request.ContentType = "application/json";
request.Accept = "application/json";
request.ContentLength = body.Length;
request.AllowAutoRedirect = false;
request.Timeout = 10_000;
request.ReadWriteTimeout = 10_000;
request.Proxy = endpoint.IsLoopback || string.IsNullOrEmpty(config.proxy_url)
? null : new WebProxy(config.proxy_url);
log.LogInfo("Requesting client resource policy: origin=" + config.server_origin +
" endpoint=" + endpoint.AbsoluteUri + " mode=" + config.cdn_mode + " method=PUT");
try
{
using (Stream stream = request.GetRequestStream())
{
stream.Write(body, 0, body.Length);
}
using var response = (HttpWebResponse)request.GetResponse();
log.LogInfo("Client resource policy response: origin=" + config.server_origin +
" status=" + (int)response.StatusCode);
if (response.StatusCode != HttpStatusCode.OK)
{
throw new InvalidDataException("resource policy returned HTTP " + (int)response.StatusCode);
}
using Stream responseStream = response.GetResponseStream();
return ParsePolicy(ReadBoundedStream(responseStream, MaximumResponseBytes));
}
catch (WebException ex)
{
int status = ex.Response is HttpWebResponse response ? (int)response.StatusCode : 0;
ex.Response?.Close();
log.LogError("Client resource policy failed: origin=" + config.server_origin +
" status=" + status + " network_status=" + ex.Status);
throw new InvalidOperationException(
"Could not load " + config.cdn_mode + " resource policy from " + endpoint.AbsoluteUri,
ex);
}
}
private static ClientConfig ParseConfig(string json)
{
JObject value = ParseObject(json, "client configuration");
RequireOnly(value, "schema_version", "server_origin", "cdn_mode", "local_resource_directory", "proxy_url");
var config = new ClientConfig
{
schema_version = RequiredInteger(value, "schema_version"),
server_origin = RequiredString(value, "server_origin"),
cdn_mode = RequiredString(value, "cdn_mode"),
local_resource_directory = OptionalString(value, "local_resource_directory"),
proxy_url = NormalizeClientProxy(OptionalString(value, "proxy_url"))
};
if (config.schema_version != 2)
{
throw new InvalidDataException("bd2.client.json schema_version must be 2");
}
if (config.cdn_mode != "official" && config.cdn_mode != "local" && config.cdn_mode != "server")
{
throw new InvalidDataException("bd2.client.json cdn_mode must be official, local, or server");
}
if (config.cdn_mode == "local" && string.IsNullOrWhiteSpace(config.local_resource_directory))
{
throw new InvalidDataException("local_resource_directory is required when cdn_mode is local");
}
if (config.cdn_mode != "local" && !string.IsNullOrWhiteSpace(config.local_resource_directory))
{
throw new InvalidDataException("local_resource_directory is only valid when cdn_mode is local");
}
return config;
}
private static string NormalizeClientProxy(string raw)
{
raw = raw.Trim();
if (raw.Length == 0) return string.Empty;
const string error = "proxy_url must be an HTTP proxy address with an explicit port and no credentials or path";
if (raw.IndexOfAny(InvalidOriginCharacters) >= 0) throw new InvalidDataException(error);
foreach (char character in raw)
if (char.IsWhiteSpace(character) || char.IsControl(character)) throw new InvalidDataException(error);
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri proxy) || proxy.Scheme != "http" ||
string.IsNullOrEmpty(proxy.Host) || !string.IsNullOrEmpty(proxy.UserInfo) ||
!string.IsNullOrEmpty(proxy.Query) || !string.IsNullOrEmpty(proxy.Fragment) || proxy.AbsolutePath != "/")
throw new InvalidDataException(error);
string authority = raw[(raw.IndexOf("://", StringComparison.Ordinal) + 3)..].TrimEnd('/');
int separator = authority.LastIndexOf(':');
if (separator <= 0) throw new InvalidDataException(error);
string portText = authority[(separator + 1)..];
foreach (char character in portText) if (character < '0' || character > '9') throw new InvalidDataException(error);
if (!int.TryParse(portText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture,
out int port) || port < 1 || port > 65535) throw new InvalidDataException(error);
string host = proxy.IdnHost.Trim('[', ']');
if (proxy.HostNameType == UriHostNameType.IPv6) host = "[" + host + "]";
return "http://" + host + ":" + port.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
private static ResourcePolicy ParsePolicy(string json)
{
JObject value = ParseObject(json, "resource policy");
RequireOnly(value, "mode", "server_data_url", "game_data_url", "bundle_version", "game_data_version");
return new ResourcePolicy
{
mode = RequiredString(value, "mode"),
server_data_url = RequiredString(value, "server_data_url"),
game_data_url = RequiredString(value, "game_data_url"),
bundle_version = RequiredString(value, "bundle_version"),
game_data_version = RequiredString(value, "game_data_version")
};
}
private static JObject ParseObject(string json, string description)
{
using var input = new StringReader(json);
using var reader = new JsonTextReader(input)
{
DateParseHandling = DateParseHandling.None
};
JObject result;
try
{
result = JObject.Load(reader, new JsonLoadSettings
{
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error
});
}
catch (JsonException ex)
{
throw new InvalidDataException("Invalid " + description + " JSON", ex);
}
if (reader.Read())
{
throw new InvalidDataException(description + " contains trailing JSON");
}
return result;
}
private static void ValidatePolicy(string requestedMode, ResourcePolicy policy)
{
if (policy.mode != requestedMode)
{
throw new InvalidDataException("resource policy mode " + policy.mode + " does not match requested mode " + requestedMode);
}
ValidateResourceURL(policy.server_data_url, "server_data_url");
ValidateResourceURL(policy.game_data_url, "game_data_url");
if (policy.bundle_version != Bd2Build.Versions.Bundle)
{
throw new InvalidDataException("resource policy bundle_version does not match this client release");
}
if (policy.game_data_version != Bd2Build.Versions.GameData)
{
throw new InvalidDataException("resource policy game_data_version does not match this client release");
}
policy.server_data_url = policy.server_data_url.TrimEnd('/');
policy.game_data_url = policy.game_data_url.TrimEnd('/');
}
private static void ValidateOrigin(string raw, string name)
{
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) ||
!string.IsNullOrEmpty(uri.UserInfo) || uri.AbsolutePath != "/" ||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) ||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
{
throw new InvalidDataException(name + " must be an HTTPS origin, except that loopback may use HTTP");
}
}
private static void ValidateResourceURL(string raw, string name)
{
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) ||
!string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) ||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
{
throw new InvalidDataException(name + " must be HTTPS, except that loopback may use HTTP");
}
}
private static string ReadBoundedFile(string path, int maximumBytes)
{
if (!File.Exists(path))
{
throw new FileNotFoundException("Client routing configuration is missing", path);
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return ReadBoundedStream(stream, maximumBytes);
}
private static string ReadBoundedStream(Stream stream, int maximumBytes)
{
using var output = new MemoryStream();
byte[] buffer = new byte[4096];
while (true)
{
int read = stream.Read(buffer, 0, Math.Min(buffer.Length, maximumBytes + 1 - (int)output.Length));
if (read == 0)
{
break;
}
output.Write(buffer, 0, read);
if (output.Length > maximumBytes)
{
throw new InvalidDataException("JSON document exceeds " + maximumBytes + " bytes");
}
}
return new UTF8Encoding(false, true).GetString(output.ToArray());
}
private static void RequireOnly(JObject value, params string[] names)
{
foreach (JProperty property in value.Properties())
{
if (Array.IndexOf(names, property.Name) < 0)
{
throw new InvalidDataException("unknown JSON field " + property.Name);
}
}
}
private static string RequiredString(JObject value, string name)
{
JToken token = value[name];
if (token == null || token.Type != JTokenType.String || string.IsNullOrEmpty((string)token))
{
throw new InvalidDataException(name + " must be a non-empty string");
}
return (string)token;
}
private static int RequiredInteger(JObject value, string name)
{
JToken token = value[name];
if (token == null || token.Type != JTokenType.Integer)
{
throw new InvalidDataException(name + " must be an integer");
}
return (int)token;
}
private static string OptionalString(JObject value, string name)
{
JToken token = value[name];
if (token == null || token.Type == JTokenType.Null)
{
return string.Empty;
}
if (token.Type != JTokenType.String)
{
throw new InvalidDataException(name + " must be a string when present");
}
return (string)token;
}
private sealed class ClientConfig
{
public int schema_version;
public string server_origin;
public string cdn_mode;
public string local_resource_directory;
public string proxy_url;
}
private sealed class ResourcePolicy
{
public string mode;
public string server_data_url;
public string game_data_url;
public string bundle_version;
public string game_data_version;
}
}