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();
}
}