layers_DNS.js

const { OsiModelLayers } = require('./osi');
const { inetPton, inetNtop } = require('#lib/converters');
const { AF_INET, AF_INET6 } = require('#lib/socket');
const { parseName, serializeName, nameLength } = require('./dnsLabel');
const { makeLayer, attach } = require('./define');

const TYPE_A = 1;
const TYPE_NS = 2;
const TYPE_CNAME = 5;
const TYPE_PTR = 12;
const TYPE_MX = 15;
const TYPE_TXT = 16;
const TYPE_AAAA = 28;
const TYPE_SRV = 33;
const TYPE_OPT = 41;

function parseRdata(message, type, rdataOffset, rdlength) {
  const rdataBuf = message.slice(rdataOffset, rdataOffset + rdlength);
  switch (type) {
    case TYPE_A:
      return rdataBuf.length === 4 ? inetNtop(AF_INET, rdataBuf) : rdataBuf;
    case TYPE_AAAA:
      return rdataBuf.length === 16 ? inetNtop(AF_INET6, rdataBuf.subarray()) : rdataBuf;
    case TYPE_NS:
    case TYPE_CNAME:
    case TYPE_PTR:
      return parseName(message, rdataOffset).name;
    case TYPE_MX: {
      if (rdlength < 3) return rdataBuf;
      const preference = rdataBuf.readUInt16BE(0);
      const exchange = parseName(message, rdataOffset + 2).name;
      return { preference, exchange };
    }
    case TYPE_TXT: {
      const strs = [];
      let p = 0;
      while (p < rdataBuf.length) {
        const l = rdataBuf[p];
        p++;
        if (p + l > rdataBuf.length) break;
        strs.push(rdataBuf.slice(p, p + l).toString('ascii'));
        p += l;
      }
      return strs.length === 1 ? strs[0] : strs;
    }
    case TYPE_SRV: {
      if (rdlength < 7) return rdataBuf;
      const priority = rdataBuf.readUInt16BE(0);
      const weight = rdataBuf.readUInt16BE(2);
      const port = rdataBuf.readUInt16BE(4);
      const target = parseName(message, rdataOffset + 6).name;
      return { priority, weight, port, target };
    }
    case TYPE_OPT: {
      // RFC 6891 sec 6.1.2 - OPT RDATA is a stream of {option-code, option-length, option-data}.
      const opts = [];
      let p = 0;
      while (p + 4 <= rdataBuf.length) {
        const code = rdataBuf.readUInt16BE(p);
        const len = rdataBuf.readUInt16BE(p + 2);
        p += 4;
        if (p + len > rdataBuf.length) break;
        opts.push({ code, data: Buffer.from(rdataBuf.slice(p, p + len)) });
        p += len;
      }
      return opts;
    }
    default:
      return rdataBuf;
  }
}

function serializeRdata(type, rdata) {
  switch (type) {
    case TYPE_A:
      return inetPton(AF_INET, rdata);
    case TYPE_AAAA:
      return inetPton(AF_INET6, rdata);
    case TYPE_NS:
    case TYPE_CNAME:
    case TYPE_PTR:
      return serializeName(rdata);
    case TYPE_MX: {
      const pref = Buffer.alloc(2);
      pref.writeUInt16BE(rdata.preference, 0);
      return Buffer.concat([pref, serializeName(rdata.exchange)]);
    }
    case TYPE_TXT: {
      const arr = Array.isArray(rdata) ? rdata : [rdata];
      const parts = [];
      for (const s of arr) {
        const b = Buffer.from(String(s), 'ascii');
        if (b.length > 255) throw new Error('DNS TXT: string too long');
        parts.push(Buffer.from([b.length]), b);
      }
      return Buffer.concat(parts);
    }
    case TYPE_SRV: {
      const head = Buffer.alloc(6);
      head.writeUInt16BE(rdata.priority ?? 0, 0);
      head.writeUInt16BE(rdata.weight ?? 0, 2);
      head.writeUInt16BE(rdata.port ?? 0, 4);
      return Buffer.concat([head, serializeName(rdata.target ?? '')]);
    }
    case TYPE_OPT: {
      const arr = Array.isArray(rdata) ? rdata : [];
      const parts = [];
      for (const o of arr) {
        const data = Buffer.isBuffer(o.data) ? o.data : Buffer.from(o.data ?? []);
        const head = Buffer.alloc(4);
        head.writeUInt16BE(o.code ?? 0, 0);
        head.writeUInt16BE(data.length, 2);
        parts.push(head, data);
      }
      return parts.length ? Buffer.concat(parts) : Buffer.alloc(0);
    }
    default:
      return Buffer.isBuffer(rdata) ? rdata : Buffer.from(rdata ?? []);
  }
}

function serializeQuestion(q) {
  const name = serializeName(q.name);
  const tail = Buffer.alloc(4);
  tail.writeUInt16BE(q.type ?? TYPE_A, 0);
  tail.writeUInt16BE(q.class ?? 1, 2);
  return Buffer.concat([name, tail]);
}

function serializeRR(rr) {
  const name = serializeName(rr.name);
  const rdata = serializeRdata(rr.type, rr.rdata);
  const tail = Buffer.alloc(10);
  tail.writeUInt16BE(rr.type, 0);
  tail.writeUInt16BE(rr.class ?? 1, 2);
  tail.writeUInt32BE(rr.ttl ?? 0, 4);
  tail.writeUInt16BE(rdata.length, 8);
  return Buffer.concat([name, tail, rdata]);
}

function rrLength(rr) {
  return serializeRR(rr).length;
}

function questionLength(q) {
  return nameLength(q.name) + 4;
}

/**
 * DNS protocol layer (RFC 1035) - read-write with one important caveat:
 * write-side name compression is not implemented in v1. Built buffers will
 * therefore be longer than typical wireshark-captured frames, but parse-side
 * compression (resolving 0xC0xx pointers) is fully supported.
 *
 * Supported RR types with structured rdata: A, AAAA, NS, CNAME, PTR, MX,
 * TXT, SRV, OPT (EDNS0). Other types expose `rdata` as a raw Buffer.
 *
 * @class
 * @implements {Layer}
 * @property {number} id - Transaction identifier.
 * @property {number} qr - Query/response flag (0 = query, 1 = response).
 * @property {number} opcode - 4-bit query opcode.
 * @property {number} aa - Authoritative answer flag.
 * @property {number} tc - Truncation flag.
 * @property {number} rd - Recursion desired flag.
 * @property {number} ra - Recursion available flag.
 * @property {number} rcode - 4-bit response code.
 * @property {number} qdCount - Number of questions.
 * @property {number} anCount - Number of answers.
 * @property {number} nsCount - Number of authority records.
 * @property {number} arCount - Number of additional records.
 */
const DNS = (() => {
  // DNS header bit layout (bytes 2-3, MSB->LSB on the wire):
  //   QR(1) Opcode(4) AA(1) TC(1) RD(1) RA(1) Z(3) RCODE(4)
  // struct-compile places the first declared field in the lowest bit, so
  // declarations below are reversed relative to the RFC layout.
  const { Layer, proto, baseLength } = makeLayer('DNS', `
    //@NE
    struct DNSHeader {
      uint16_t id;
      uint16_t rcode:4, z:3, ra:1, rd:1, tc:1, aa:1, opcode:4, qr:1;
      uint16_t qdCount;
      uint16_t anCount;
      uint16_t nsCount;
      uint16_t arCount;
    } __attribute__(packed);
  `, {
    osi: OsiModelLayers.Application,
    length: ($) => $._buf.length,
    toAlloc: (data) => calcLength(data),
  });

  /**
   * Total serialized DNS message length for a given object, with no name
   * compression. Used by Packet._build via Layer.toAlloc.
   */
  function calcLength(data) {
    if (!data) return baseLength;
    let len = baseLength;
    for (const q of data.questions ?? []) len += questionLength(q);
    for (const rr of data.answers ?? []) len += rrLength(rr);
    for (const rr of data.authority ?? []) len += rrLength(rr);
    for (const rr of data.additional ?? []) len += rrLength(rr);
    return len;
  }
  Layer.toAlloc = calcLength;

  // Walk `count` entries starting at `bodyOffset` (relative to body start,
  // i.e. baseLength bytes after the buffer start). Used by all section
  // getters.
  proto._parseSection = function _parseSection(bodyOffset, count, kind) {
    const message = this._buf;
    const items = [];
    let cur = baseLength + bodyOffset;
    for (let i = 0; i < count; i++) {
      if (kind === 'q') {
        const { name, next } = parseName(message, cur);
        if (next + 4 > message.length) throw new Error('DNS: question truncated');
        items.push({
          name,
          type: message.readUInt16BE(next),
          class: message.readUInt16BE(next + 2),
        });
        cur = next + 4;
      }
      else {
        const { name, next: afterName } = parseName(message, cur);
        if (afterName + 10 > message.length) throw new Error('DNS: RR header truncated');
        const type = message.readUInt16BE(afterName);
        const cls = message.readUInt16BE(afterName + 2);
        const ttl = message.readUInt32BE(afterName + 4);
        const rdlength = message.readUInt16BE(afterName + 8);
        const rdataOffset = afterName + 10;
        if (rdataOffset + rdlength > message.length) throw new Error('DNS: RR data truncated');
        items.push({
          name,
          type,
          class: cls,
          ttl,
          rdata: parseRdata(message, type, rdataOffset, rdlength),
        });
        cur = rdataOffset + rdlength;
      }
    }
    return { items, next: cur - baseLength };
  };

  // Sections are parsed lazily on each access so the underlying buffer
  // remains the source of truth. Each getter walks earlier sections to
  // find its starting offset; this matches the legacy implementation
  // verbatim and stays cheap because parseName itself short-circuits on
  // compressed pointers.
  attach.virtualField(proto, 'questions', {
    get() { return this._parseSection(0, this.qdCount, 'q').items; },
  });
  attach.virtualField(proto, 'answers', {
    get() {
      const q = this._parseSection(0, this.qdCount, 'q');
      return this._parseSection(q.next, this.anCount, 'rr').items;
    },
  });
  attach.virtualField(proto, 'authority', {
    get() {
      const q = this._parseSection(0, this.qdCount, 'q');
      const an = this._parseSection(q.next, this.anCount, 'rr');
      return this._parseSection(an.next, this.nsCount, 'rr').items;
    },
  });
  attach.virtualField(proto, 'additional', {
    get() {
      const q = this._parseSection(0, this.qdCount, 'q');
      const an = this._parseSection(q.next, this.anCount, 'rr');
      const ns = this._parseSection(an.next, this.nsCount, 'rr');
      return this._parseSection(ns.next, this.arCount, 'rr').items;
    },
  });

  /**
   * High-level view of the EDNS0 OPT pseudo-RR (RFC 6891) embedded in the
   * additional section. Returns null when no OPT record is present.
   *
   * The OPT RR repurposes standard RR fields:
   *   - class -> sender's UDP payload size
   *   - ttl   -> [extendedRcode(8) | version(8) | DO(1) | Z(15)]
   *   - rdata -> stream of {code, data} options (parsed elsewhere)
   */
  attach.virtualField(proto, 'opt', {
    get() {
      for (const rr of this.additional) {
        if (rr.type === TYPE_OPT) {
          const ttl = rr.ttl >>> 0;
          return {
            udpPayloadSize: rr.class,
            extendedRcode: (ttl >>> 24) & 0xff,
            version: (ttl >>> 16) & 0xff,
            doFlag: (ttl >>> 15) & 0x1,
            options: Array.isArray(rr.rdata) ? rr.rdata : [],
          };
        }
      }
      return null;
    },
  });

  // The struct-compile merge handles header fields. We then patch counts
  // from the section arrays' lengths and serialize sections (no
  // compression) into the body.
  const baseMerge = proto.merge;
  proto.merge = function(data) {
    if (data == null) return;
    if (Buffer.isBuffer(data)) {
      baseMerge.call(this, data);
      return;
    }

    const stripped = { ...data };
    delete stripped.questions;
    delete stripped.answers;
    delete stripped.authority;
    delete stripped.additional;
    baseMerge.call(this, stripped);

    if (data.questions) this.qdCount = data.questions.length;
    if (data.answers) this.anCount = data.answers.length;
    if (data.authority) this.nsCount = data.authority.length;
    if (data.additional) this.arCount = data.additional.length;

    let cursor = baseLength;
    for (const q of data.questions ?? []) {
      const buf = serializeQuestion(q);
      buf.copy(this._buf, cursor);
      cursor += buf.length;
    }
    for (const rr of data.answers ?? []) {
      const buf = serializeRR(rr);
      buf.copy(this._buf, cursor);
      cursor += buf.length;
    }
    for (const rr of data.authority ?? []) {
      const buf = serializeRR(rr);
      buf.copy(this._buf, cursor);
      cursor += buf.length;
    }
    for (const rr of data.additional ?? []) {
      const buf = serializeRR(rr);
      buf.copy(this._buf, cursor);
      cursor += buf.length;
    }
  };

  attach.toObjectExtras(proto, ['questions', 'answers', 'authority', 'additional']);

  attach.defaults(proto, {
    qr: 0,
    opcode: 0,
  });

  return Layer;
})();

/**
 * Construct an EDNS0 OPT pseudo-RR suitable for placing in the `additional`
 * section. All fields default to sensible values: 4096-byte UDP buffer,
 * version 0, DO flag clear, no options.
 *
 * @param {Object} [opt]
 * @param {number} [opt.udpPayloadSize=4096]
 * @param {number} [opt.extendedRcode=0]
 * @param {number} [opt.version=0]
 * @param {number} [opt.doFlag=0]
 * @param {Array.<{code: number, data: Buffer|Array}>} [opt.options=[]]
 * @returns {{name: string, type: number, class: number, ttl: number, rdata: Array}}
 */
DNS.opt = function makeOpt(opt = {}) {
  const ttl = (
    ((opt.extendedRcode ?? 0) & 0xff) * 0x1000000 +
    ((opt.version ?? 0) & 0xff) * 0x10000 +
    ((opt.doFlag ?? 0) & 0x1) * 0x8000
  ) >>> 0;
  return {
    name: '',
    type: TYPE_OPT,
    class: opt.udpPayloadSize ?? 4096,
    ttl,
    rdata: opt.options ?? [],
  };
};

DNS.TYPES = {
  A: TYPE_A,
  NS: TYPE_NS,
  CNAME: TYPE_CNAME,
  PTR: TYPE_PTR,
  MX: TYPE_MX,
  TXT: TYPE_TXT,
  AAAA: TYPE_AAAA,
  SRV: TYPE_SRV,
  OPT: TYPE_OPT,
};

module.exports = { DNS };