Uwaa/HTTP/HttpServer.cs

238 lines
7.4 KiB
C#

using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using Uwaa.HTTP.Routing;
using Uwaa.HTTP.Websockets;
namespace Uwaa.HTTP;
/// <summary>
/// A hypertext transfer protocol server which can listen on a port and deliver content over HTTP, optionally over SSL (HTTPS).
/// </summary>
public sealed class HttpServer
{
/// <summary>
/// The router to be used to find and serve content to clients.
/// </summary>
public readonly RouterBase Router;
/// <summary>
/// If non-null, all connections will be secured via SSL using this certificate.
/// </summary>
public readonly X509Certificate? Certificate;
/// <summary>
/// Maximum number of connections per IP before new connections are rejected.
/// </summary>
/// <remarks>
/// Prevents Slow Loris attacks.
/// </remarks>
public int MaxConnections = 20;
/// <summary>
/// The port the server will listen on.
/// </summary>
public int Port => ((IPEndPoint)listener.LocalEndpoint).Port;
/// <summary>
/// Called when a client establishes a connection with the server.
/// </summary>
public event Action<TcpClient>? OnConnectionBegin;
/// <summary>
/// Called when a connection has terminated.
/// </summary>
public event Action<IPEndPoint>? OnConnectionEnd;
/// <summary>
/// Called when a request has been served a response.
/// </summary>
public event Action<HttpRequest, HttpClientInfo, HttpResponse>? OnResponse;
/// <summary>
/// Called when a request causes an exception.
/// </summary>
public event Action<IPEndPoint, Exception>? OnException;
/// <summary>
/// The maximum time the socket may be inactive before it is presumed dead and closed.
/// </summary>
public TimeSpan Timeout = TimeSpan.FromSeconds(20);
readonly Dictionary<IPAddress, int> IPCounts = new Dictionary<IPAddress, int>();
readonly SemaphoreSlim IPCountsLock = new SemaphoreSlim(1, 1);
readonly TcpListener listener;
public HttpServer(int port, X509Certificate? certificate, RouterBase router)
{
if (OperatingSystem.IsWindows() && certificate is X509Certificate2 cert)
{
//Hack because SslStream is stupid on windows
certificate = new X509Certificate2(cert.Export(X509ContentType.Pfx));
}
listener = new TcpListener(IPAddress.Any, port);
Certificate = certificate;
Router = router;
}
/// <summary>
/// Begins listening for new connections.
/// </summary>
public async void Start()
{
listener.Start();
while (true)
HandleClient(await listener.AcceptTcpClientAsync());
}
async void HandleClient(TcpClient client)
{
OnConnectionBegin?.Invoke(client);
if (client.Client.RemoteEndPoint is not IPEndPoint endpoint)
return;
//Count IPs to prevent Slow Loris attacks
try
{
await IPCountsLock.WaitAsync();
if (IPCounts.TryGetValue(endpoint.Address, out int count))
{
if (count >= MaxConnections)
{
//Too many connections
client.Close();
return;
}
IPCounts[endpoint.Address] = count + 1;
}
else
{
IPCounts[endpoint.Address] = 1;
}
}
finally
{
IPCountsLock.Release();
}
try
{
//Setup client
Stream stream = client.GetStream();
client.LingerState = new LingerOption(true, 5);
if (Certificate != null)
{
//Pass through SSL stream
SslStream ssl = new SslStream(stream);
await ssl.AuthenticateAsServerAsync(Certificate).WaitAsync(Timeout);
stream = ssl;
}
//HTTP request-response loop
while (client.Connected)
{
HttpStream httpStream = new HttpStream(stream);
try
{
HttpClientInfo clientInfo = new HttpClientInfo(client, endpoint);
HttpRequest req = await httpStream.ReadRequest().WaitAsync(Timeout);
//Parse path
ArraySegment<string> pathSpl = req.Path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSpl.Count == 0)
{
pathSpl = new string[] { string.Empty };
}
else
{
for (int i = 0; i < pathSpl.Count; i++)
pathSpl[i] = WebUtility.UrlDecode(pathSpl[i]);
}
//Execute
HttpResponse? response = (await Router.GetResponse(req, clientInfo, pathSpl)) ?? HttpResponse.NotFound("Router produced no response");
OnResponse?.Invoke(req, clientInfo, response);
await response.WriteTo(httpStream).WaitAsync(Timeout);
if (response is SwitchingProtocols swp)
{
//Create and run websocket
WebsocketRemote ws = new WebsocketRemote(req, clientInfo, httpStream, swp.Fields.WebSocketProtocol);
CloseStatus closeStatus = await swp.Callback(ws);
await ws.Close(closeStatus).WaitAsync(Timeout);
break; //Close
}
if (req.Fields.Connection == ConnectionType.Close)
break; //Close
}
catch (HttpException e)
{
try
{
await new HttpResponse(400, e.Message).WriteTo(httpStream).WaitAsync(Timeout);
}
catch { }
throw;
}
catch (TimeoutException)
{
//Timeout
throw;
}
catch (IOException)
{
//Disconnect
throw;
}
catch (SocketException)
{
//Disconnect
throw;
}
catch (Exception)
{
await new HttpResponse(500, "Internal server error").WriteTo(httpStream).WaitAsync(Timeout);
throw;
}
}
}
catch (Exception e)
{
//Swallow exceptions to prevent the server from crashing.
//When debugging, use a debugger to break on exceptions.
OnException?.Invoke(endpoint, e);
}
finally
{
client.Close();
try
{
await IPCountsLock.WaitAsync();
if (IPCounts[endpoint.Address] <= 1)
{
IPCounts.Remove(endpoint.Address);
}
else
{
IPCounts[endpoint.Address]--;
}
}
finally
{
IPCountsLock.Release();
}
OnConnectionEnd?.Invoke(endpoint);
}
}
}