Newer
Older
Sunfish / src / Sunfish.Core / Net / Discovery.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 12 KB 3.0
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using Sunfish.Log;

namespace Sunfish.Net;

/// <summary>Un servidor encontrado en la red.</summary>
public sealed record DiscoveredServer(string Name, IPAddress Address, int Port, string Path, bool IsSunfish)
{
    public string Url => LocalAddresses.HttpUrl(Address, Port) .TrimEnd('/') + (Path.StartsWith('/') ? Path : "/" + Path);
}

/// <summary>Lo que un Sunfish anuncia.</summary>
public sealed record AnnouncedServices(string Name, int HttpPort, bool WebDav, int FtpPort);

/// <summary>
/// DNS-SD sobre mDNS (RFC 6762/6763), sin bibliotecas externas.
/// El anunciante escucha en 224.0.0.251:5353 (compartido con Avahi, Bonjour o el mDNS de Windows
/// gracias a ReuseAddress) y responde por _http._tcp, _webdav._tcp y _ftp._tcp. Si no puede abrir
/// el puerto 5353 lo anota y sigue sin anunciar. La busqueda pregunta desde un puerto cualquiera:
/// los respondedores contestan directamente a ese puerto ("legacy unicast").
/// </summary>
public static class Discovery
{
    public const int Port = 5353;
    public static readonly IPAddress Group = IPAddress.Parse("224.0.0.251");
    private static readonly IPEndPoint GroupEndPoint = new(Group, Port);

    public const string HttpService = "_http._tcp.local";
    public const string WebDavService = "_webdav._tcp.local";
    public const string FtpService = "_ftp._tcp.local";
    private const string ServicesQuery = "_services._dns-sd._udp.local";

    /// <summary>Busca servidores WebDAV en la LAN durante <paramref name="wait"/>.</summary>
    public static async Task<List<DiscoveredServer>> BrowseAsync(TimeSpan wait, CancellationToken ct = default)
    {
        var found = new Dictionary<string, DiscoveredServer>(StringComparer.OrdinalIgnoreCase);
        using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
        socket.Bind(new IPEndPoint(IPAddress.Any, 0));
        var query = new DnsMessage { Id = (ushort)Random.Shared.Next(1, 65535) };
        query.Questions.Add((WebDavService, DnsType.Ptr, true));
        byte[] packet = query.Encode();

        // Por cada interfaz IPv4 activa, para llegar a todas las redes del equipo.
        foreach (var local in LocalIPv4())
        {
            try
            {
                socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, local.GetAddressBytes());
                await socket.SendToAsync(packet, SocketFlags.None, GroupEndPoint, ct);
            }
            catch (SocketException) { }
        }

        var records = new List<DnsRecord>();
        var sources = new Dictionary<string, IPAddress>(StringComparer.OrdinalIgnoreCase);
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeout.CancelAfter(wait);
        var buffer = new byte[9000];
        try
        {
            while (true)
            {
                var result = await socket.ReceiveFromAsync(buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), timeout.Token);
                var message = DnsMessage.Decode(buffer.AsSpan(0, result.ReceivedBytes));
                if (message is null || !message.IsResponse)
                    continue;
                var all = message.Answers.Concat(message.Additional).ToList();
                records.AddRange(all);
                foreach (var r in all.Where(r => r.Type == DnsType.Srv))
                    sources[r.Name] = ((IPEndPoint)result.RemoteEndPoint).Address;
            }
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested) { }

        foreach (var ptr in records.Where(r => r.Type == DnsType.Ptr && r.Name.Equals(WebDavService, StringComparison.OrdinalIgnoreCase)))
        {
            string instance = ptr.Target!;
            var srv = records.LastOrDefault(r => r.Type == DnsType.Srv && r.Name.Equals(instance, StringComparison.OrdinalIgnoreCase));
            if (srv is null)
                continue;
            var txt = records.LastOrDefault(r => r.Type == DnsType.Txt && r.Name.Equals(instance, StringComparison.OrdinalIgnoreCase));
            var address = records.LastOrDefault(r => r.Type == DnsType.A && r.Name.Equals(srv.Target, StringComparison.OrdinalIgnoreCase))?.Address
                          ?? sources.GetValueOrDefault(instance);
            if (address is null)
                continue;
            string path = txt?.Text.FirstOrDefault(t => t.StartsWith("path=", StringComparison.Ordinal))?[5..] ?? "/";
            bool sunfish = txt?.Text.Any(t => t.StartsWith("sunfish=", StringComparison.Ordinal)) == true;
            string name = instance.EndsWith("." + WebDavService, StringComparison.OrdinalIgnoreCase)
                ? instance[..^(WebDavService.Length + 1)]
                : instance;
            found[instance] = new DiscoveredServer(name, address, srv.Port, path, sunfish);
        }
        return found.Values.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
    }

    internal static List<IPAddress> LocalIPv4() =>
        LocalAddresses.Get().Select(a => a.Address).Where(a => a.AddressFamily == AddressFamily.InterNetwork).ToList();
}

/// <summary>Responde a las preguntas mDNS sobre este Sunfish mientras esta en marcha.</summary>
public sealed class MdnsAnnouncer : IAsyncDisposable
{
    private readonly ServerLog _log;
    private readonly Socket _socket;
    private readonly CancellationTokenSource _stop = new();
    private readonly Task _loop;
    private volatile AnnouncedServices _services;
    private readonly string _host;
    private readonly string _instance;

    private MdnsAnnouncer(Socket socket, AnnouncedServices services, ServerLog log)
    {
        _socket = socket;
        _services = services;
        _log = log;
        string machine = new string(Environment.MachineName.Where(c => char.IsAsciiLetterOrDigit(c) || c == '-').ToArray());
        if (machine.Length == 0)
            machine = "pc";
        _host = $"sunfish-{machine.ToLowerInvariant()}.local";
        _instance = $"{services.Name} ({machine})";
        _loop = ReceiveLoopAsync();
    }

    /// <summary>Empieza a anunciar; null si no se puede abrir el puerto 5353 (queda anotado).</summary>
    public static MdnsAnnouncer? Start(AnnouncedServices services, ServerLog log)
    {
        var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
        try
        {
            socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
            socket.Bind(new IPEndPoint(IPAddress.Any, Discovery.Port));
            foreach (var local in Discovery.LocalIPv4())
            {
                try
                {
                    socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(Discovery.Group, local));
                }
                catch (SocketException) { }
            }
            socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255);
            socket.MulticastLoopback = true;
        }
        catch (SocketException e)
        {
            socket.Dispose();
            log.Warning(Strings.MdnsUnavailable(e.Message));
            return null;
        }
        var announcer = new MdnsAnnouncer(socket, services, log);
        _ = announcer.AnnounceAsync(); // anuncio no solicitado al arrancar (dos veces, RFC 6762 8.3)
        log.Info(Strings.MdnsAnnouncing(announcer._instance));
        return announcer;
    }

    public void Update(AnnouncedServices services)
    {
        _services = services;
        _ = AnnounceAsync();
    }

    private async Task AnnounceAsync()
    {
        for (int i = 0; i < 2 && !_stop.IsCancellationRequested; i++)
        {
            await SendAsync(BuildResponse(null, 0, null), Discovery.Group, Discovery.Port);
            try { await Task.Delay(1000, _stop.Token); } catch (OperationCanceledException) { return; }
        }
    }

    private async Task ReceiveLoopAsync()
    {
        var buffer = new byte[9000];
        while (!_stop.IsCancellationRequested)
        {
            SocketReceiveFromResult result;
            try
            {
                result = await _socket.ReceiveFromAsync(buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), _stop.Token);
            }
            catch (OperationCanceledException) { return; }
            catch (ObjectDisposedException) { return; }
            catch (SocketException) { continue; }

            var query = DnsMessage.Decode(buffer.AsSpan(0, result.ReceivedBytes));
            if (query is null || query.IsResponse || query.Questions.Count == 0)
                continue;
            var remote = (IPEndPoint)result.RemoteEndPoint;
            var response = BuildResponse(query.Questions.Select(q => (q.Name, q.Type)).ToList(), query.Id, remote);
            if (response is null)
                continue;
            // Legacy unicast: quien pregunta desde otro puerto recibe la respuesta en ese puerto.
            if (remote.Port != Discovery.Port || query.Questions.Any(q => q.UnicastResponse))
                await SendAsync(response, remote.Address, remote.Port);
            else
                await SendAsync(response, Discovery.Group, Discovery.Port);
        }
    }

    /// <summary>Respuesta para las preguntas dadas (null = anuncio completo); null si no nos toca.</summary>
    private byte[]? BuildResponse(List<(string Name, DnsType Type)>? questions, ushort id, IPEndPoint? remote)
    {
        var s = _services;
        var types = new List<(string Type, int Port, string[] Txt)>();
        if (s.HttpPort > 0)
            types.Add((Discovery.HttpService, s.HttpPort, ["sunfish=3", "path=/"]));
        if (s.HttpPort > 0 && s.WebDav)
            types.Add((Discovery.WebDavService, s.HttpPort, ["sunfish=3", "path=/"]));
        if (s.FtpPort > 0)
            types.Add((Discovery.FtpService, s.FtpPort, ["sunfish=3", "path=/"]));
        if (types.Count == 0)
            return null;

        var message = new DnsMessage { Id = remote is not null && remote.Port != Discovery.Port ? id : (ushort)0, IsResponse = true };
        bool answered = false;
        foreach (var (type, port, txt) in types)
        {
            string instance = $"{_instance}.{type}";
            bool wanted = questions is null || questions.Any(q =>
                (q.Name.Equals(type, StringComparison.OrdinalIgnoreCase) && q.Type is DnsType.Ptr or DnsType.Any) ||
                q.Name.Equals(instance, StringComparison.OrdinalIgnoreCase));
            if (questions is not null && questions.Any(q => q.Name.Equals("_services._dns-sd._udp.local", StringComparison.OrdinalIgnoreCase)))
            {
                message.Answers.Add(new DnsRecord("_services._dns-sd._udp.local", DnsType.Ptr, 4500) { Target = type });
                answered = true;
            }
            if (!wanted)
                continue;
            answered = true;
            message.Answers.Add(new DnsRecord(type, DnsType.Ptr, 4500) { Target = instance });
            message.Additional.Add(new DnsRecord(instance, DnsType.Srv, 120) { Target = _host, Port = (ushort)port, CacheFlush = true });
            message.Additional.Add(new DnsRecord(instance, DnsType.Txt, 4500) { Text = txt, CacheFlush = true });
        }
        if (questions is not null && questions.Any(q => q.Name.Equals(_host, StringComparison.OrdinalIgnoreCase)))
            answered = true;
        if (!answered)
            return null;
        foreach (var address in Discovery.LocalIPv4())
            message.Additional.Add(new DnsRecord(_host, DnsType.A, 120) { Address = address, CacheFlush = true });
        if (remote is not null && remote.Port != Discovery.Port)
            foreach (var (name, type) in questions!)
                message.Questions.Add((name, type, false));
        return message.Encode();
    }

    private async Task SendAsync(byte[]? packet, IPAddress address, int port)
    {
        if (packet is null)
            return;
        try
        {
            await _socket.SendToAsync(packet, SocketFlags.None, new IPEndPoint(address, port), _stop.Token);
        }
        catch (Exception e) when (e is SocketException or OperationCanceledException or ObjectDisposedException) { }
    }

    public async ValueTask DisposeAsync()
    {
        _stop.Cancel();
        _socket.Dispose();
        try { await _loop; } catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) { }
        _stop.Dispose();
    }
}