using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Sunfish.Log;
namespace Sunfish.Http;
/// <summary>Peticion y respuesta en curso.</summary>
public sealed class HttpContext
{
internal HttpContext(HttpRequest request, HttpResponse response, CancellationToken aborted)
{
Request = request;
Response = response;
Aborted = aborted;
}
public HttpRequest Request { get; }
public HttpResponse Response { get; }
/// <summary>Se cancela al parar el servidor.</summary>
public CancellationToken Aborted { get; }
}
/// <summary>
/// Servidor HTTP propio sobre <see cref="Socket"/>. No usa http.sys (que en Windows exige
/// administrador para escuchar fuera de localhost) ni Kestrel (estricto con clientes antiguos).
/// </summary>
public sealed class HttpServer : IAsyncDisposable
{
public const int MaxConnectionsPerAddress = 32;
private const long MaxDrainBytes = 1 << 20;
private readonly IReadOnlyList<IPAddress> _bind;
private readonly int _port;
private readonly Func<HttpContext, Task> _handler;
private readonly ServerLog _log;
private readonly List<Socket> _listeners = new();
private readonly ConcurrentDictionary<Task, byte> _connections = new();
private readonly ConcurrentDictionary<IPAddress, int> _perAddress = new();
private readonly CancellationTokenSource _shutdown = new();
private readonly List<Task> _acceptLoops = new();
public HttpServer(IReadOnlyList<IPAddress> bind, int port, Func<HttpContext, Task> handler, ServerLog log)
{
_bind = bind;
_port = port;
_handler = handler;
_log = log;
}
/// <summary>Direcciones en las que escucha de verdad (con el puerto real si se pidio el 0).</summary>
public IReadOnlyList<IPEndPoint> Endpoints =>
_listeners.Select(l => (IPEndPoint)l.LocalEndPoint!).ToList();
/// <summary>Abre los sockets. Lanza <see cref="SocketException"/> si el puerto esta ocupado.</summary>
public void Start()
{
foreach (var (address, dualMode) in PlanSockets(_bind))
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
if (address.AddressFamily == AddressFamily.InterNetworkV6)
socket.DualMode = dualMode;
if (OperatingSystem.IsWindows())
socket.ExclusiveAddressUse = true;
else
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
socket.Bind(new IPEndPoint(address, _port));
socket.Listen(128);
}
catch
{
socket.Dispose();
foreach (var opened in _listeners)
opened.Dispose();
_listeners.Clear();
throw;
}
_listeners.Add(socket);
}
foreach (var listener in _listeners)
{
_log.Info(Strings.HttpListening(listener.LocalEndPoint!.ToString()!));
_acceptLoops.Add(AcceptLoopAsync(listener));
}
}
/// <summary>
/// 0.0.0.0 y :: a la vez se resuelven con un unico socket IPv6 en modo dual (en Linux
/// abrir los dos daria "direccion en uso").
/// </summary>
internal static List<(IPAddress Address, bool DualMode)> PlanSockets(IReadOnlyList<IPAddress> bind)
{
var plan = new List<(IPAddress, bool)>();
bool any4 = bind.Contains(IPAddress.Any);
bool any6 = bind.Contains(IPAddress.IPv6Any) && Socket.OSSupportsIPv6;
if (any4 && any6)
plan.Add((IPAddress.IPv6Any, true));
else if (any4)
plan.Add((IPAddress.Any, false));
else if (any6)
plan.Add((IPAddress.IPv6Any, false));
foreach (var address in bind)
{
if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))
continue;
if (address.AddressFamily == AddressFamily.InterNetworkV6 && !Socket.OSSupportsIPv6)
continue;
plan.Add((address, false));
}
return plan;
}
private async Task AcceptLoopAsync(Socket listener)
{
while (!_shutdown.IsCancellationRequested)
{
Socket client;
try
{
client = await listener.AcceptAsync(_shutdown.Token);
}
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
catch (SocketException) { continue; }
var remote = (IPEndPoint)client.RemoteEndPoint!;
var address = Normalize(remote.Address);
int count = _perAddress.AddOrUpdate(address, 1, (_, c) => c + 1);
if (count > MaxConnectionsPerAddress)
{
Release(address);
client.Dispose();
continue;
}
var task = HandleConnectionAsync(client, remote);
_connections.TryAdd(task, 0);
_ = task.ContinueWith(t =>
{
_connections.TryRemove(t, out _);
Release(address);
}, TaskScheduler.Default);
}
}
private void Release(IPAddress address) =>
_perAddress.AddOrUpdate(address, 0, (_, c) => Math.Max(0, c - 1));
private static IPAddress Normalize(IPAddress address) =>
address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
private async Task HandleConnectionAsync(Socket client, IPEndPoint remote)
{
client.NoDelay = true;
var displayRemote = new IPEndPoint(Normalize(remote.Address), remote.Port);
var local = (IPEndPoint)client.LocalEndPoint!;
var localNormalized = new IPEndPoint(Normalize(local.Address), local.Port);
await using var stream = new TimeoutStream(new NetworkStream(client, ownsSocket: true), _shutdown.Token);
var reader = new HttpRequestReader(stream) { Remote = displayRemote, Local = localNormalized };
try
{
while (!_shutdown.IsCancellationRequested)
{
HttpRequest? request;
try
{
request = await reader.ReadRequestAsync(
() => stream.WriteAsync("HTTP/1.1 100 Continue\r\n\r\n"u8.ToArray()).AsTask(),
_shutdown.Token);
}
catch (HttpParseException e)
{
await SendBareErrorAsync(stream, e.Status);
_log.Detail($"{displayRemote}: {e.Status} {e.Message}");
return;
}
if (request is null)
return;
var response = new HttpResponse(stream, request);
var context = new HttpContext(request, response, _shutdown.Token);
try
{
await _handler(context);
}
catch (Exception e) when (e is not OperationCanceledException && !response.HeadersSent)
{
_log.Error($"{displayRemote} {request.Method} {request.Path}: {e.Message}");
response.StatusCode = 500;
await response.SendAsync(Encoding.ASCII.GetBytes("500 Internal Server Error"), "text/plain");
}
await response.CompleteAsync(_shutdown.Token);
if (!response.KeepAlive)
return;
if (request.Body is RequestBodyStream body && !await body.DrainAsync(MaxDrainBytes, _shutdown.Token))
return;
}
}
catch (Exception e) when (e is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
// Cliente que cierra, plazo agotado o parada del servidor: no es un error del servidor.
_log.Detail(Strings.ConnectionError(displayRemote.ToString(), e.Message));
}
catch (Exception e)
{
_log.Error(Strings.ConnectionError(displayRemote.ToString(), e.Message));
}
}
private static async Task SendBareErrorAsync(Stream stream, int status)
{
try
{
string body = $"{status} {HttpResponse.ReasonPhrase(status)}";
string head = $"HTTP/1.0 {status} {HttpResponse.ReasonPhrase(status)}\r\nContent-Type: text/plain\r\n" +
$"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n";
await stream.WriteAsync(Encoding.ASCII.GetBytes(head + body));
}
catch (Exception e) when (e is IOException or OperationCanceledException or ObjectDisposedException) { }
}
public async Task StopAsync()
{
if (_shutdown.IsCancellationRequested)
return;
_shutdown.Cancel();
foreach (var listener in _listeners)
listener.Dispose();
try
{
await Task.WhenAll(_acceptLoops.Concat(_connections.Keys)).WaitAsync(TimeSpan.FromSeconds(5));
}
catch (Exception e) when (e is TimeoutException or OperationCanceledException) { }
_log.Info(Strings.HttpStopped);
}
public async ValueTask DisposeAsync()
{
await StopAsync();
_shutdown.Dispose();
}
}