From 597af24edb6b670c961b85c860cde7e2ca4e09d1 Mon Sep 17 00:00:00 2001 From: ReinforceZwei <39955851+ReinforceZwei@users.noreply.github.com> Date: Fri, 2 Dec 2022 21:15:05 +0800 Subject: [PATCH] ProxiedWebRequest: Support http/1.1 (#2357) * ProxiedWebRequest: Support http/1.1 - Support chunked transfer - Refactor the code * Check cookie expire --- MinecraftClient/Protocol/ProxiedWebRequest.cs | 330 ++++++++++++++---- 1 file changed, 262 insertions(+), 68 deletions(-) diff --git a/MinecraftClient/Protocol/ProxiedWebRequest.cs b/MinecraftClient/Protocol/ProxiedWebRequest.cs index 73d9fe26..1e028abb 100644 --- a/MinecraftClient/Protocol/ProxiedWebRequest.cs +++ b/MinecraftClient/Protocol/ProxiedWebRequest.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Linq; using System.Globalization; using System.IO; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; using System.Text; +using System.Threading; using MinecraftClient.Proxy; namespace MinecraftClient.Protocol @@ -16,12 +18,21 @@ namespace MinecraftClient.Protocol /// public class ProxiedWebRequest { - private readonly string httpVersion = "HTTP/1.0"; // Use 1.0 here because 1.1 server may send chunked data + public interface ITcpFactory + { + TcpClient CreateTcpClient(string host, int port); + }; + + private readonly string httpVersion = "HTTP/1.1"; + + private ITcpFactory? tcpFactory; + private bool isProxied = false; // Send absolute Url in request if true private readonly Uri uri; private string Host { get { return uri.Host; } } private int Port { get { return uri.Port; } } private string Path { get { return uri.PathAndQuery; } } + private string AbsoluteUrl { get { return uri.AbsoluteUri; } } private bool IsSecure { get { return uri.Scheme == "https"; } } public NameValueCollection Headers = new(); @@ -30,6 +41,12 @@ namespace MinecraftClient.Protocol public string Accept { get { return Headers.Get("Accept") ?? String.Empty; } set { Headers.Set("Accept", value); } } public string Cookie { set { Headers.Set("Cookie", value); } } + /// + /// Set to true to tell the http client proxy is enabled + /// + public bool IsProxy { get { return isProxied; } set { isProxied = value; } } + public bool Debug { get { return Settings.Config.Logging.DebugMessages; } } + /// /// Create a new http request /// @@ -52,13 +69,34 @@ namespace MinecraftClient.Protocol SetupBasicHeaders(); } + /// + /// Create a new http request with custom tcp client + /// + /// Tcp factory to be used + /// Target URL + public ProxiedWebRequest(ITcpFactory tcpFactory, string url) : this(url) + { + this.tcpFactory = tcpFactory; + } + + /// + /// Create a new http request with custom tcp client and cookies + /// + /// Tcp factory to be used + /// Target URL + /// Cookies to use + public ProxiedWebRequest(ITcpFactory tcpFactory, string url, NameValueCollection cookies) : this(url, cookies) + { + this.tcpFactory = tcpFactory; + } + /// /// Setup some basic headers /// private void SetupBasicHeaders() { Headers.Add("Host", Host); - Headers.Add("User-Agent", "MCC/" + Program.Version); + Headers.Add("User-Agent", "MCC/1.0"); Headers.Add("Accept", "*/*"); Headers.Add("Connection", "close"); } @@ -96,7 +134,7 @@ namespace MinecraftClient.Protocol { List requestMessage = new() { - string.Format("{0} {1} {2}", method.ToUpper(), Path, httpVersion) // Request line + string.Format("{0} {1} {2}", method.ToUpper(), isProxied ? AbsoluteUrl : Path, httpVersion) // Request line }; foreach (string key in Headers) // Headers { @@ -109,7 +147,7 @@ namespace MinecraftClient.Protocol requestMessage.Add(body); } else requestMessage.Add(""); // - if (Settings.Config.Logging.DebugMessages) + if (Debug) { foreach (string l in requestMessage) { @@ -117,84 +155,240 @@ namespace MinecraftClient.Protocol } } Response response = Response.Empty(); - AutoTimeout.Perform(() => + + // FIXME: Use TcpFactory interface to avoid direct usage of the ProxyHandler class + // TcpClient client = tcpFactory.CreateTcpClient(Host, Port); + TcpClient client = ProxyHandler.NewTcpClient(Host, Port, true); + Stream stream; + if (IsSecure) { - TcpClient client = ProxyHandler.NewTcpClient(Host, Port, true); - Stream stream; - if (IsSecure) - { - stream = new SslStream(client.GetStream()); - ((SslStream)stream).AuthenticateAsClient(Host, null, SslProtocols.Tls12, true); // Enable TLS 1.2. Hotfix for #1774 - } - else - { - stream = client.GetStream(); - } - string h = string.Join("\r\n", requestMessage.ToArray()); - byte[] data = Encoding.ASCII.GetBytes(h); - stream.Write(data, 0, data.Length); - stream.Flush(); - StreamReader sr = new(stream); - string rawResult = sr.ReadToEnd(); - response = ParseResponse(rawResult); - try - { - sr.Close(); - stream.Close(); - client.Close(); - } - catch { } - }, - TimeSpan.FromSeconds(30)); + stream = new SslStream(client.GetStream()); + ((SslStream)stream).AuthenticateAsClient(Host, null, SslProtocols.Tls12, true); // Enable TLS 1.2. Hotfix for #1774 + } + else + { + stream = client.GetStream(); + } + string h = string.Join("\r\n", requestMessage.ToArray()); + byte[] data = Encoding.ASCII.GetBytes(h); + stream.Write(data, 0, data.Length); + stream.Flush(); + + // Read response + int statusCode = ReadHttpStatus(stream); + var headers = ReadHeader(stream); + string? rbody; + if (headers.Get("transfer-encoding") == "chunked") + { + rbody = ReadBodyChunked(stream); + } + else + { + rbody = ReadBody(stream, int.Parse(headers.Get("content-length") ?? "0")); + } + if (headers.Get("set-cookie") != null) + { + response.Cookies = ParseSetCookie(headers.GetValues("set-cookie") ?? Array.Empty()); + } + response.Body = rbody ?? ""; + response.StatusCode = statusCode; + response.Headers = headers; + + try + { + stream.Close(); + client.Close(); + } + catch { } + return response; } /// - /// Parse a raw response string to response object + /// Read HTTP response line from a Stream /// - /// raw response string + /// Stream to read /// - private Response ParseResponse(string raw) + /// If server return unknown data + private static int ReadHttpStatus(Stream s) { - int statusCode; - string responseBody = ""; - NameValueCollection headers = new(); - NameValueCollection cookies = new(); - if (raw.StartsWith("HTTP/1.1") || raw.StartsWith("HTTP/1.0")) + var httpHeader = ReadLine(s); // http header line + if (httpHeader.StartsWith("HTTP/1.1") || httpHeader.StartsWith("HTTP/1.0")) { - Queue msg = new(raw.Split(new string[] { "\r\n" }, StringSplitOptions.None)); - statusCode = int.Parse(msg.Dequeue().Split(' ')[1], NumberStyles.Any, CultureInfo.CurrentCulture); - - while (msg.Peek() != "") - { - string[] header = msg.Dequeue().Split(new char[] { ':' }, 2); // Split first ':' only - string key = header[0].ToLower(); // Key is case-insensitive - string value = header[1]; - if (key == "set-cookie") - { - string[] cookie = value.Split(';'); // cookie options are ignored - string[] tmp = cookie[0].Split(new char[] { '=' }, 2); // Split first '=' only - string cname = tmp[0].Trim(); - string cvalue = tmp[1].Trim(); - cookies.Add(cname, cvalue); - } - else - { - headers.Add(key, value.Trim()); - } - } - msg.Dequeue(); - if (msg.Count > 0) - responseBody = msg.Dequeue(); - - return new Response(statusCode, responseBody, headers, cookies); + return int.Parse(httpHeader.Split(' ')[1], NumberStyles.Any, CultureInfo.CurrentCulture); } else { - return new Response(520 /* Web Server Returned an Unknown Error */, "", headers, cookies); + throw new InvalidDataException("Unexpect data from server"); } } + /// + /// Read HTTP headers from a Stream + /// + /// Stream to read + /// Headers in lower-case + private static NameValueCollection ReadHeader(Stream s) + { + var headers = new NameValueCollection(); + // Read headers + string header; + do + { + header = ReadLine(s); + if (!String.IsNullOrEmpty(header)) + { + var tmp = header.Split(new char[] { ':' }, 2); + var name = tmp[0].ToLower(); + var value = tmp[1].Trim(); + headers.Add(name, value); + } + } + while (!String.IsNullOrEmpty(header)); + return headers; + } + + /// + /// Read HTTP body from a Stream + /// + /// Stream to read + /// Length of the body (the Content-Length header) + /// Body or null if length is zero + private static string? ReadBody(Stream s, int length) + { + if (length > 0) + { + byte[] buffer = new byte[length]; + int r = 0; + while (r < length) + { + var read = s.Read(buffer, r, length - r); + r += read; + Thread.Sleep(50); + } + return Encoding.UTF8.GetString(buffer); + } + else + { + return null; + } + } + + /// + /// Read HTTP chunked body from a Stream + /// + /// Stream to read + /// Body or empty string if nothing is received + private static string ReadBodyChunked(Stream s) + { + List buffer1 = new(); + while (true) + { + string l = ReadLine(s); + int size = Int32.Parse(l, NumberStyles.HexNumber); + if (size == 0) + break; + byte[] buffer2 = new byte[size]; + int r = 0; + while (r < size) + { + var read = s.Read(buffer2, r, size - r); + r += read; + Thread.Sleep(50); + } + ReadLine(s); + buffer1.AddRange(buffer2); + } + return Encoding.UTF8.GetString(buffer1.ToArray()); + } + + /// + /// Parse the Set-Cookie header value into NameValueCollection. Cookie options are ignored + /// + /// Array of value strings + /// Parsed cookies + private static NameValueCollection ParseSetCookie(IEnumerable headerValue) + { + NameValueCollection cookies = new(); + foreach (var value in headerValue) + { + string[] cookie = value.Split(';'); // cookie options are ignored + string[] tmp = cookie[0].Split(new char[] { '=' }, 2); // Split first '=' only + string[] options = cookie[1..]; + string cname = tmp[0].Trim(); + string cvalue = tmp[1].Trim(); + // Check expire + bool isExpired = false; + foreach (var option in options) + { + var tmp2 = option.Trim().Split(new char[] { '=' }, 2); + // Check for Expires= and Max-Age= + if (tmp2.Length == 2) + { + var optName = tmp2[0].Trim().ToLower(); + var optValue = tmp2[1].Trim(); + switch (optName) + { + case "expires": + { + if (DateTime.TryParse(optValue, out var expDate)) + { + if (expDate < DateTime.Now) + isExpired = true; + } + break; + } + case "max-age": + { + if (int.TryParse(optValue, out var expInt)) + { + if (expInt <= 0) + isExpired = true; + } + break; + } + } + } + if (isExpired) + break; + } + if (!isExpired) + cookies.Add(cname, cvalue); + } + return cookies; + } + + /// + /// Read a line from a Stream + /// + /// + /// Line break by \r\n and they are not included in returned string + /// + /// Stream to read + /// String + private static string ReadLine(Stream s) + { + List buffer = new(); + byte c; + while (true) + { + int b = s.ReadByte(); + if (b == -1) + break; + c = (byte)b; + if (c == '\n') + { + if (buffer.Last() == '\r') + { + buffer.RemoveAt(buffer.Count - 1); + break; + } + } + buffer.Add(c); + } + return Encoding.UTF8.GetString(buffer.ToArray()); + } + /// /// Get the cookie string representation to use in header /// @@ -271,4 +465,4 @@ namespace MinecraftClient.Protocol } } } -} +} \ No newline at end of file