Newer
Older
Sunfish / src / Sunfish.Core / Net / DnsMessage.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 7 KB 3.0
using System.Buffers.Binary;
using System.Net;
using System.Text;

namespace Sunfish.Net;

public enum DnsType : ushort { A = 1, Ptr = 12, Txt = 16, Aaaa = 28, Srv = 33, Any = 255 }

/// <summary>Un registro de recurso DNS (solo los tipos que usa DNS-SD).</summary>
public sealed record DnsRecord(string Name, DnsType Type, uint Ttl)
{
    public string? Target { get; init; }                 // PTR, SRV
    public ushort Port { get; init; }                    // SRV
    public IPAddress? Address { get; init; }             // A, AAAA
    public IReadOnlyList<string> Text { get; init; } = []; // TXT
    public bool CacheFlush { get; init; }
}

/// <summary>
/// Mensaje DNS minimo para mDNS (RFC 6762) y DNS-SD (RFC 6763): preguntas y registros PTR, SRV,
/// TXT, A y AAAA. Al leer se entienden los nombres comprimidos; al escribir no se comprime.
/// </summary>
public sealed class DnsMessage
{
    public ushort Id { get; set; }
    public bool IsResponse { get; set; }
    public List<(string Name, DnsType Type, bool UnicastResponse)> Questions { get; } = new();
    public List<DnsRecord> Answers { get; } = new();
    public List<DnsRecord> Additional { get; } = new();

    public byte[] Encode()
    {
        var b = new List<byte>(512);
        void U16(int v) { b.Add((byte)(v >> 8)); b.Add((byte)v); }
        void U32(uint v) { U16((int)(v >> 16)); U16((int)(v & 0xFFFF)); }
        U16(Id);
        U16(IsResponse ? 0x8400 : 0); // QR + AA
        U16(Questions.Count);
        U16(Answers.Count);
        U16(0);
        U16(Additional.Count);
        foreach (var (name, type, unicast) in Questions)
        {
            WriteName(b, name);
            U16((ushort)type);
            U16(unicast ? 0x8001 : 0x0001);
        }
        foreach (var r in Answers.Concat(Additional))
        {
            WriteName(b, r.Name);
            U16((ushort)r.Type);
            U16(r.CacheFlush ? 0x8001 : 0x0001);
            U32(r.Ttl);
            var data = new List<byte>();
            switch (r.Type)
            {
                case DnsType.Ptr:
                    WriteName(data, r.Target!);
                    break;
                case DnsType.Srv:
                    data.AddRange([0, 0, 0, 0, (byte)(r.Port >> 8), (byte)r.Port]);
                    WriteName(data, r.Target!);
                    break;
                case DnsType.Txt:
                    if (r.Text.Count == 0)
                        data.Add(0);
                    foreach (var item in r.Text)
                    {
                        var bytes = Encoding.UTF8.GetBytes(item);
                        data.Add((byte)Math.Min(255, bytes.Length));
                        data.AddRange(bytes.Take(255));
                    }
                    break;
                case DnsType.A or DnsType.Aaaa:
                    data.AddRange(r.Address!.GetAddressBytes());
                    break;
            }
            U16(data.Count);
            b.AddRange(data);
        }
        return b.ToArray();
    }

    private static void WriteName(List<byte> b, string name)
    {
        foreach (var label in SplitLabels(name))
        {
            var bytes = Encoding.UTF8.GetBytes(label);
            b.Add((byte)Math.Min(63, bytes.Length));
            b.AddRange(bytes.Take(63));
        }
        b.Add(0);
    }

    /// <summary>
    /// "Sunfish (equipo)._http._tcp.local" -> etiquetas. La primera de un nombre de instancia puede
    /// contener puntos, asi que se separa por el tipo de servicio conocido.
    /// </summary>
    internal static List<string> SplitLabels(string name)
    {
        name = name.TrimEnd('.');
        int service = name.IndexOf("._", StringComparison.Ordinal);
        if (service > 0)
        {
            var labels = new List<string> { name[..service] };
            labels.AddRange(name[(service + 1)..].Split('.'));
            return labels;
        }
        return name.Split('.').ToList();
    }

    public static DnsMessage? Decode(ReadOnlySpan<byte> data)
    {
        try
        {
            var m = new DnsMessage
            {
                Id = BinaryPrimitives.ReadUInt16BigEndian(data),
                IsResponse = (data[2] & 0x80) != 0,
            };
            int qd = BinaryPrimitives.ReadUInt16BigEndian(data[4..]);
            int an = BinaryPrimitives.ReadUInt16BigEndian(data[6..]);
            int ns = BinaryPrimitives.ReadUInt16BigEndian(data[8..]);
            int ar = BinaryPrimitives.ReadUInt16BigEndian(data[10..]);
            int pos = 12;
            for (int i = 0; i < qd; i++)
            {
                string name = ReadName(data, ref pos);
                var type = (DnsType)BinaryPrimitives.ReadUInt16BigEndian(data[pos..]);
                int cls = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]);
                pos += 4;
                m.Questions.Add((name, type, (cls & 0x8000) != 0));
            }
            for (int i = 0; i < an + ns + ar; i++)
            {
                var record = ReadRecord(data, ref pos);
                if (record is null)
                    continue;
                if (i < an)
                    m.Answers.Add(record);
                else
                    m.Additional.Add(record);
            }
            return m;
        }
        catch (Exception e) when (e is ArgumentOutOfRangeException or IndexOutOfRangeException or InvalidDataException)
        {
            return null;
        }
    }

    private static DnsRecord? ReadRecord(ReadOnlySpan<byte> data, ref int pos)
    {
        string name = ReadName(data, ref pos);
        var type = (DnsType)BinaryPrimitives.ReadUInt16BigEndian(data[pos..]);
        int cls = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]);
        uint ttl = BinaryPrimitives.ReadUInt32BigEndian(data[(pos + 4)..]);
        int length = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 8)..]);
        pos += 10;
        int start = pos;
        int end = pos + length;
        if (end > data.Length)
            throw new InvalidDataException();
        pos = end;
        var r = new DnsRecord(name, type, ttl) { CacheFlush = (cls & 0x8000) != 0 };
        int p = start;
        switch (type)
        {
            case DnsType.Ptr:
                return r with { Target = ReadName(data, ref p) };
            case DnsType.Srv:
                p += 4;
                ushort port = BinaryPrimitives.ReadUInt16BigEndian(data[p..]);
                p += 2;
                return r with { Port = port, Target = ReadName(data, ref p) };
            case DnsType.Txt:
                var items = new List<string>();
                while (p < end)
                {
                    int len = data[p++];
                    if (len > 0)
                        items.Add(Encoding.UTF8.GetString(data.Slice(p, Math.Min(len, end - p))));
                    p += len;
                }
                return r with { Text = items };
            case DnsType.A when length == 4:
                return r with { Address = new IPAddress(data.Slice(start, 4)) };
            case DnsType.Aaaa when length == 16:
                return r with { Address = new IPAddress(data.Slice(start, 16)) };
            default:
                return null;
        }
    }

    private static string ReadName(ReadOnlySpan<byte> data, ref int pos)
    {
        var labels = new List<string>();
        int p = pos;
        bool jumped = false;
        for (int hops = 0; hops < 64; hops++)
        {
            int len = data[p];
            if (len == 0)
            {
                if (!jumped)
                    pos = p + 1;
                return string.Join('.', labels);
            }
            if ((len & 0xC0) == 0xC0)
            {
                int target = ((len & 0x3F) << 8) | data[p + 1];
                if (!jumped)
                    pos = p + 2;
                jumped = true;
                p = target;
                continue;
            }
            labels.Add(Encoding.UTF8.GetString(data.Slice(p + 1, len)));
            p += len + 1;
        }
        throw new InvalidDataException("Name loop");
    }
}