Newer
Older
Sunfish / src / Sunfish.Core / Ftp / FtpServer.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 4 KB 3.0
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Sunfish.Auth;
using Sunfish.Http;
using Sunfish.Log;
using Sunfish.Web;

namespace Sunfish.Ftp;

/// <summary>
/// Servidor FTP sobre el mismo arbol de shares que la web. Cada conexion de control es una
/// <see cref="FtpSession"/>. Sin cifrado, a proposito (ver DOC/SEGURIDAD.md).
/// </summary>
public sealed class FtpServer : IAsyncDisposable
{
    public const int MaxConnectionsPerAddress = 16;

    private readonly Func<ServerState> _state;
    private readonly ServerLog _log;
    private readonly Authenticator _auth;
    private readonly List<Socket> _listeners = new();
    private readonly List<Task> _acceptLoops = new();
    private readonly ConcurrentDictionary<Task, byte> _sessions = new();
    private readonly ConcurrentDictionary<IPAddress, int> _perAddress = new();
    private readonly CancellationTokenSource _shutdown = new();

    public FtpServer(Func<ServerState> state, ServerLog log, Authenticator auth)
    {
        _state = state;
        _log = log;
        _auth = auth;
    }

    public IReadOnlyList<IPEndPoint> Endpoints => _listeners.Select(l => (IPEndPoint)l.LocalEndPoint!).ToList();

    /// <summary>Abre los sockets de control. Lanza <see cref="SocketException"/> si el puerto esta ocupado.</summary>
    public void Start()
    {
        var server = _state().Config.Server;
        foreach (var (address, dualMode) in HttpServer.PlanSockets(server.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, server.FtpPort));
                socket.Listen(64);
            }
            catch
            {
                socket.Dispose();
                foreach (var opened in _listeners)
                    opened.Dispose();
                _listeners.Clear();
                throw;
            }
            _listeners.Add(socket);
        }
        foreach (var listener in _listeners)
        {
            _log.Info(Strings.FtpListening(listener.LocalEndPoint!.ToString()!));
            _acceptLoops.Add(AcceptLoopAsync(listener));
        }
    }

    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 address = DataChannel.Normalize(((IPEndPoint)client.RemoteEndPoint!).Address);
            if (_perAddress.AddOrUpdate(address, 1, (_, c) => c + 1) > MaxConnectionsPerAddress)
            {
                Release(address);
                client.Dispose();
                continue;
            }
            var session = new FtpSession(client, _state, _log, _auth, _shutdown.Token);
            var task = session.RunAsync();
            _sessions.TryAdd(task, 0);
            _ = task.ContinueWith(t =>
            {
                _sessions.TryRemove(t, out _);
                Release(address);
            }, TaskScheduler.Default);
        }
    }

    private void Release(IPAddress address) =>
        _perAddress.AddOrUpdate(address, 0, (_, c) => Math.Max(0, c - 1));

    public async Task StopAsync()
    {
        if (_shutdown.IsCancellationRequested)
            return;
        _shutdown.Cancel();
        foreach (var listener in _listeners)
            listener.Dispose();
        try
        {
            await Task.WhenAll(_acceptLoops.Concat(_sessions.Keys)).WaitAsync(TimeSpan.FromSeconds(5));
        }
        catch (Exception e) when (e is TimeoutException or OperationCanceledException) { }
        _log.Info(Strings.FtpStopped);
    }

    public async ValueTask DisposeAsync()
    {
        await StopAsync();
        _shutdown.Dispose();
    }
}