Files
bd2/plugins/LocalIdentity/ClientRouting.cs
T

400 lines
16 KiB
C#

using System;
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 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;
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));
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()
{
localResourceServer?.Dispose();
}
internal void ApplyServerOrigin()
{
Type serverURLInfo = Plugin.FindType("ὫὡὩὤὣὨὯὭὥὠὩ") ?? Plugin.FindType("BDNetwork.ServerURLInfo");
var setUriDirectly = serverURLInfo?.GetMethod(
"ὬὠὢὮὪὮὭὫὮὡὥ",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
null,
new[] { typeof(Uri) },
null) ?? serverURLInfo?.GetMethod(
"SetUriDirectly",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
null,
new[] { typeof(Uri) },
null);
if (setUriDirectly == null)
{
throw new MissingMethodException("BDNetwork.ServerURLInfo.SetUriDirectly(Uri) was not found");
}
Uri gameEndpoint = new Uri(new Uri(config.server_origin + "/"), "game/");
setUriDirectly.Invoke(null, new object[] { gameEndpoint });
log.LogInfo("Client server origin applied: " + config.server_origin);
}
internal void ApplyResources()
{
Type cdnInfo = Plugin.FindType("BDNetwork.CdnInfo");
if (cdnInfo == null)
{
throw new TypeLoadException("BDNetwork.CdnInfo was not found");
}
SetStaticString(cdnInfo, "Info", resources.server_data_url);
SetStaticString(cdnInfo, "Version", resources.bundle_version);
SetStaticString(cdnInfo, "SoundVersion", resources.bundle_version);
SetStaticString(cdnInfo, "GameDataInfo", resources.game_data_url);
SetStaticString(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)
{
Uri endpoint = new Uri(new Uri(config.server_origin + "/"), "client/resources");
byte[] body = Encoding.UTF8.GetBytes("{\"cdn_mode\":\"" + config.cdn_mode + "\"}");
HttpWebRequest 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;
if (endpoint.IsLoopback)
{
// UnityWebRequest can inherit a system proxy even for loopback.
// The configured private server must be contacted directly.
request.Proxy = null;
}
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 HttpWebResponse 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");
ClientConfig 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")
};
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 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 StringReader input = new StringReader(json);
using JsonTextReader 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 FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return ReadBoundedStream(stream, maximumBytes);
}
private static string ReadBoundedStream(Stream stream, int maximumBytes)
{
using MemoryStream 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 static void SetStaticString(Type type, string propertyName, string value)
{
var property = type.GetProperty(propertyName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public);
if (property?.PropertyType != typeof(string) || property.GetSetMethod() == null)
{
throw new MissingMemberException(type.FullName + "." + propertyName + " was not found");
}
property.SetValue(null, value, null);
}
private sealed class ClientConfig
{
public int schema_version;
public string server_origin;
public string cdn_mode;
public string local_resource_directory;
}
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;
}
}