Newer
Older
Sunfish / src / Sunfish.Core / Ftp / DataChannel.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 5 KB 3.0
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using Sunfish.Http;

namespace Sunfish.Ftp;

/// <summary>
/// Conexion de datos de una sesion FTP, en modo pasivo (el cliente se conecta a un puerto que
/// abre el servidor) o activo (el servidor se conecta al cliente). Por seguridad solo se aceptan
/// conexiones de, o hacia, la misma IP que abrio el control (evita el "FTP bounce" y el robo de
/// la conexion de datos), y en activo nunca a puertos por debajo de 1024.
/// </summary>
internal sealed class DataChannel : IDisposable
{
    public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(30);

    private readonly IPAddress _client;
    private Socket? _passive;
    private IPEndPoint? _active;

    public DataChannel(IPAddress client) => _client = Normalize(client);

    public bool Ready => _passive is not null || _active is not null;

    /// <summary>Abre un puerto pasivo en la direccion local indicada; devuelve el puerto.</summary>
    public int OpenPassive(IPAddress local, string range)
    {
        Reset();
        var (first, last) = ParseRange(range);
        List<int> candidates = first == 0
            ? [0]
            : Enumerable.Range(first, last - first + 1).OrderBy(_ => Random.Shared.Next()).ToList();
        foreach (int port in candidates)
        {
            var socket = new Socket(local.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
            try
            {
                socket.Bind(new IPEndPoint(local, port));
                socket.Listen(1);
                _passive = socket;
                return ((IPEndPoint)socket.LocalEndPoint!).Port;
            }
            catch (SocketException)
            {
                socket.Dispose();
            }
        }
        throw new SocketException((int)SocketError.AddressAlreadyInUse);
    }

    /// <summary>PORT/EPRT: guarda el destino si es la IP del cliente y un puerto no privilegiado.</summary>
    public bool SetActive(IPEndPoint endpoint)
    {
        if (!Normalize(endpoint.Address).Equals(_client) || endpoint.Port < 1024)
            return false;
        Reset();
        _active = endpoint;
        return true;
    }

    /// <summary>Establece la conexion de datos preparada (y la consume).</summary>
    public async Task<Stream> ConnectAsync(CancellationToken ct)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeout.CancelAfter(ConnectTimeout);
        try
        {
            if (_passive is { } listener)
            {
                while (true)
                {
                    var socket = await listener.AcceptAsync(timeout.Token);
                    if (Normalize(((IPEndPoint)socket.RemoteEndPoint!).Address).Equals(_client))
                        return Wrap(socket, ct);
                    socket.Dispose(); // otra IP: se ignora y se sigue esperando al cliente
                }
            }
            if (_active is { } target)
            {
                var socket = new Socket(target.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
                try
                {
                    await socket.ConnectAsync(target, timeout.Token);
                    return Wrap(socket, ct);
                }
                catch
                {
                    socket.Dispose();
                    throw;
                }
            }
            throw new InvalidOperationException("No data connection prepared");
        }
        finally
        {
            Reset();
        }
    }

    private static Stream Wrap(Socket socket, CancellationToken ct)
    {
        socket.NoDelay = true;
        return new TimeoutStream(new NetworkStream(socket, ownsSocket: true), ct)
        {
            ReadLimit = TimeSpan.FromSeconds(120),
            WriteLimit = TimeSpan.FromSeconds(120),
        };
    }

    public void Reset()
    {
        _passive?.Dispose();
        _passive = null;
        _active = null;
    }

    public void Dispose() => Reset();

    internal static IPAddress Normalize(IPAddress address) =>
        address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;

    /// <summary>"50000-50050" -> (50000, 50050); vacio o invalido -> (0, 0): cualquier puerto libre.</summary>
    internal static (int First, int Last) ParseRange(string range)
    {
        var parts = range.Split('-', StringSplitOptions.TrimEntries);
        if (parts.Length == 2 &&
            int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out int a) &&
            int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out int b) &&
            a is >= 1024 and <= 65535 && b >= a && b <= 65535)
            return (a, b);
        return (0, 0);
    }

    /// <summary>"h1,h2,h3,h4,p1,p2" de PORT.</summary>
    internal static IPEndPoint? ParsePort(string argument)
    {
        var n = argument.Split(',', StringSplitOptions.TrimEntries);
        if (n.Length != 6)
            return null;
        var bytes = new byte[6];
        for (int i = 0; i < 6; i++)
            if (!byte.TryParse(n[i], NumberStyles.None, CultureInfo.InvariantCulture, out bytes[i]))
                return null;
        return new IPEndPoint(new IPAddress(bytes[..4]), bytes[4] * 256 + bytes[5]);
    }

    /// <summary>"|1|132.235.1.2|6275|" o "|2|::1|6275|" de EPRT.</summary>
    internal static IPEndPoint? ParseEprt(string argument)
    {
        if (argument.Length < 5)
            return null;
        char d = argument[0];
        var parts = argument.Split(d);
        if (parts.Length != 5 || !IPAddress.TryParse(parts[2], out var address) ||
            !int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out int port) || port is < 1 or > 65535)
            return null;
        return new IPEndPoint(address, port);
    }
}