using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using BepInEx.Logging; namespace Bd2LocalIdentity; internal sealed class LocalResourceServer : IDisposable { private const int MaximumRequestHeaderBytes = 16 * 1024; private const int MaximumConcurrentRequests = 16; private readonly ManualLogSource log; private readonly TcpListener listener; private readonly Thread acceptThread; private readonly Semaphore requestSlots = new Semaphore(MaximumConcurrentRequests, MaximumConcurrentRequests); private volatile bool disposed; private LocalResourceServer(ManualLogSource log, string rootDirectory) { this.log = log; RootDirectory = rootDirectory; listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); int port = ((IPEndPoint)listener.LocalEndpoint).Port; Origin = "http://127.0.0.1:" + port.ToString(CultureInfo.InvariantCulture); acceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "BD2 local resource server" }; acceptThread.Start(); } internal string Origin { get; } internal string RootDirectory { get; } internal static LocalResourceServer Start( ManualLogSource log, string configuredDirectory, string bundleVersion, string gameDataVersion) { string root = ValidateRoot(configuredDirectory, bundleVersion, gameDataVersion); LocalResourceServer server = new LocalResourceServer(log, root); log.LogInfo("Local resource HTTP server listening on " + server.Origin); return server; } private static string ValidateRoot(string configuredDirectory, string bundleVersion, string gameDataVersion) { if (string.IsNullOrWhiteSpace(configuredDirectory)) { throw new InvalidDataException("local_resource_directory must not be empty"); } string root; try { root = Path.GetFullPath(configuredDirectory.Trim()); } catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) { throw new InvalidDataException("local_resource_directory is not a valid filesystem path", ex); } if (!Directory.Exists(root)) { throw new DirectoryNotFoundException("Local resource directory does not exist: " + root); } if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) { throw new InvalidDataException("Local resource directory must not be a symbolic link or junction: " + root); } RequireDirectory(root, "ServerData"); RequireDirectory(root, "GameData"); RequireFile(root, "ServerData", "StandaloneWindows64", "HD", bundleVersion, "catalog_alpha.json"); RequireFile(root, "ServerData", "StandaloneWindows64", "HD", bundleVersion, "catalog_alpha.hash"); RequireFile(root, "GameData", gameDataVersion, "release", "common-dbdata.info"); RequireFile(root, "GameData", gameDataVersion, "release", "common-dbdata.bin"); string volumeRoot = Path.GetPathRoot(root); if (root.Length > volumeRoot.Length) { root = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } return root; } private static void RequireDirectory(string root, params string[] parts) { string path = Combine(root, parts); if (!Directory.Exists(path)) { throw new DirectoryNotFoundException("Local resource directory is missing: " + path); } } private static void RequireFile(string root, params string[] parts) { string path = Combine(root, parts); if (!File.Exists(path)) { throw new FileNotFoundException("Local resource directory is missing a release file", path); } } private static string Combine(string root, IEnumerable parts) { string result = root; foreach (string part in parts) { result = Path.Combine(result, part); } return result; } private void AcceptLoop() { while (!disposed) { TcpClient client; try { client = listener.AcceptTcpClient(); } catch (SocketException) when (disposed) { return; } catch (ObjectDisposedException) when (disposed) { return; } catch (Exception ex) { if (!disposed) { log.LogError("Local resource accept failed: " + ex.Message); } continue; } if (!requestSlots.WaitOne(0)) { client.Dispose(); continue; } ThreadPool.QueueUserWorkItem(_ => { try { HandleClient(client); } catch (Exception ex) { log.LogWarning("Local resource request failed: " + ex.Message); } finally { client.Dispose(); requestSlots.Release(); } }); } } private void HandleClient(TcpClient client) { client.ReceiveTimeout = 10_000; client.SendTimeout = 30_000; using NetworkStream stream = client.GetStream(); string header = ReadRequestHeader(stream); if (header == null) { WriteError(stream, 400, "Bad Request"); return; } string[] lines = header.Split(new[] { "\r\n" }, StringSplitOptions.None); string[] requestLine = lines[0].Split(' '); if (requestLine.Length != 3 || (requestLine[2] != "HTTP/1.1" && requestLine[2] != "HTTP/1.0")) { WriteError(stream, 400, "Bad Request"); return; } bool head = requestLine[0] == "HEAD"; if (!head && requestLine[0] != "GET") { WriteError(stream, 405, "Method Not Allowed", "Allow: GET, HEAD\r\n"); return; } Dictionary headers = ParseHeaders(lines); string path = ResolvePath(requestLine[1]); if (path == null || !File.Exists(path)) { WriteError(stream, 404, "Not Found"); return; } using FileStream file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); long start = 0; long end = file.Length - 1; bool partial = false; if (headers.TryGetValue("Range", out string range)) { if (!TryParseRange(range, file.Length, out start, out end)) { WriteError(stream, 416, "Range Not Satisfiable", "Content-Range: bytes */" + file.Length + "\r\n"); return; } partial = true; } long contentLength = file.Length == 0 ? 0 : end - start + 1; StringBuilder response = new StringBuilder(); response.Append(partial ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n"); response.Append("Content-Length: ").Append(contentLength).Append("\r\n"); response.Append("Content-Type: ").Append(GetContentType(path)).Append("\r\n"); response.Append("Accept-Ranges: bytes\r\n"); if (partial) { response.Append("Content-Range: bytes ").Append(start).Append('-').Append(end).Append('/').Append(file.Length).Append("\r\n"); } response.Append("Cache-Control: public, max-age=31536000, immutable\r\n"); response.Append("Connection: close\r\n\r\n"); WriteAscii(stream, response.ToString()); if (head || contentLength == 0) { return; } file.Position = start; byte[] buffer = new byte[128 * 1024]; long remaining = contentLength; while (remaining > 0) { int read = file.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining)); if (read == 0) { throw new EndOfStreamException("Local resource file ended before its declared length"); } stream.Write(buffer, 0, read); remaining -= read; } } private string ResolvePath(string requestTarget) { int query = requestTarget.IndexOfAny(new[] { '?', '#' }); string rawPath = query >= 0 ? requestTarget.Substring(0, query) : requestTarget; string decoded; try { decoded = Uri.UnescapeDataString(rawPath); } catch { return null; } if (decoded.IndexOf('\0') >= 0) { return null; } string[] parts = decoded.Replace('\\', '/').Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 2 || (parts[0] != "ServerData" && parts[0] != "GameData")) { return null; } foreach (string part in parts) { if (part == "." || part == ".." || part.IndexOf(':') >= 0) { return null; } } string candidate = Combine(RootDirectory, parts); try { candidate = Path.GetFullPath(candidate); } catch { return null; } string prefix = RootDirectory + Path.DirectorySeparatorChar; if (!candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || ContainsReparsePoint(parts)) { return null; } return candidate; } private bool ContainsReparsePoint(string[] parts) { string current = RootDirectory; foreach (string part in parts) { current = Path.Combine(current, part); try { if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) { return true; } } catch { return true; } } return false; } private static string ReadRequestHeader(Stream stream) { byte[] bytes = new byte[MaximumRequestHeaderBytes]; int count = 0; while (count < bytes.Length) { int read = stream.Read(bytes, count, 1); if (read == 0) { return null; } count++; if (count >= 4 && bytes[count - 4] == '\r' && bytes[count - 3] == '\n' && bytes[count - 2] == '\r' && bytes[count - 1] == '\n') { return Encoding.ASCII.GetString(bytes, 0, count - 4); } } return null; } private static Dictionary ParseHeaders(string[] lines) { Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int i = 1; i < lines.Length; i++) { int separator = lines[i].IndexOf(':'); if (separator <= 0) { continue; } result[lines[i].Substring(0, separator).Trim()] = lines[i].Substring(separator + 1).Trim(); } return result; } private static bool TryParseRange(string value, long length, out long start, out long end) { start = 0; end = length - 1; if (length == 0 || !value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase) || value.IndexOf(',') >= 0) { return false; } string[] bounds = value.Substring(6).Split('-'); if (bounds.Length != 2) { return false; } if (bounds[0].Length == 0) { if (!long.TryParse(bounds[1], NumberStyles.None, CultureInfo.InvariantCulture, out long suffix) || suffix <= 0) { return false; } start = Math.Max(0, length - suffix); return true; } if (!long.TryParse(bounds[0], NumberStyles.None, CultureInfo.InvariantCulture, out start) || start < 0 || start >= length) { return false; } if (bounds[1].Length != 0 && (!long.TryParse(bounds[1], NumberStyles.None, CultureInfo.InvariantCulture, out end) || end < start)) { return false; } end = Math.Min(end, length - 1); return true; } private static string GetContentType(string path) { switch (Path.GetExtension(path).ToLowerInvariant()) { case ".json": return "application/json; charset=utf-8"; case ".hash": case ".info": case ".version": return "text/plain; charset=utf-8"; default: return "application/octet-stream"; } } private static void WriteError(Stream stream, int status, string reason, string additionalHeaders = "") { WriteAscii(stream, "HTTP/1.1 " + status + " " + reason + "\r\n" + additionalHeaders + "Content-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"); } private static void WriteAscii(Stream stream, string value) { byte[] bytes = Encoding.ASCII.GetBytes(value); stream.Write(bytes, 0, bytes.Length); } public void Dispose() { if (disposed) { return; } disposed = true; listener.Stop(); if (Thread.CurrentThread != acceptThread) { acceptThread.Join(1_000); } log.LogInfo("Local resource HTTP server stopped"); } }