Newer
Older
Sunfish / src / Sunfish.Core / Http / HttpServer.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 9 KB 3.0
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();
    }
}