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