Files
bd2/plugins/LocalIdentity/ClientRouting.cs
T
Flechazo e4276eb493 feat(all): configure proxies, reuse native HTTP and stabilize recovery
Persist direct/manual proxy settings in Client Studio and apply them to resource,
Unity and native requests, with loopback bypass and no automatic system proxy.
Pool WinHTTP/curl connections, bound workers and deadlines, cancel safely,
and log queue and transport timings without exposing sensitive paths.
Return explicit relay failures, probe through native HTTP, retry interrupted
requests and recognize completed home, field and pack scenes during recovery.
2026-10-04 20:10:01 +08:00

442 lines
18 KiB
C#

using System;
using System.Reflection;
using BD2.GameNames;
using static BD2.GameNames.Game;
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 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;
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()
{
Uri 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)
{
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;
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 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", "proxy_url");
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"),
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(new[] { '?', '#', '@', '%' }) >= 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.Substring(raw.IndexOf("://", StringComparison.Ordinal) + 3).TrimEnd('/');
int separator = authority.LastIndexOf(':');
if (separator <= 0) throw new InvalidDataException(error);
string portText = authority.Substring(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 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 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;
}
}