DNS over HTTPS を使う

# DNS over HTTPS を使ってみる

ホスト名から IP アドレスを取得する方法が RFC 8484 で標準化されているのでテストするものを作ってみました。

DoH サーバーはデフォルトで https\://dns.google/dns-query としています。
他にも https\://cloudflare-dns.com/dns-query などがあるようです。

※ Punycode 化は行っていないので、いわゆる日本語ドメイン名などには対応していません
※ IPv4,IPv6 アドレスの短縮表現化も行っていません
※ 不足は他にもあると思います

<p class="codepen" data-height="330" data-default-tab="result" data-slug-hash="bGQwQNM" data-user="ikiuo" style="height: 330px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/ikiuo/pen/bGQwQNM">
  DNS over HTTPS テスト</a> by Ikiuo (<a href="https://codepen.io/ikiuo">@ikiuo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

```HTML:
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>DNS over HTTPS テスト</title>
  </head>
  <body>
    <script>

     /* Base64URL 符号化に使用する文字 */
     const BASE64_CHAR = ([...
         'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
         'abcdefghijklmnopqrstuvwxyz' +
         '0123456789' + '-.'
     ]);

     /* ドメイン名で使用可能な文字 */
     const DNS_CHAR = new Set(
         'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
         'abcdefghijklmnopqrstuvwxyz' +
         '0123456789' + '.-'
     );

     /* バイト列を Base64URL に符号化する */
     function encodeBase64URL(binary) {
         const table = BASE64_CHAR;
         const blen = binary.length;
         const brem = blen % 3;
         const bcnt = blen - brem;
         let text = '';
         let i = 0;
         while (i < bcnt) {
             const d0 = binary[i++];
             const d1 = binary[i++];
             const d2 = binary[i++];
             const d = (d0 << 16) | (d1 << 8) | d2;
             text += table[(d >> 18) & 0x3f];
             text += table[(d >> 12) & 0x3f];
             text += table[(d >>  6) & 0x3f];
             text += table[d & 0x3f];
         }
         if (brem) {
             const b2 = (brem == 2);
             const d0 = binary[i++];
             const d1 = (b2 ? binary[i++] : 0);
             const d = (d0 << 16) | (d1 << 8);
             text += table[(d >> 18) & 0x3f];
             text += table[(d >> 12) & 0x3f];
             if (b2) text += table[(d >> 6) & 0x3f];
         }
         return text;
     }

     /* DNS 質問を作る */
     function createDNSQuery(id, qtype, hostname) {
         const binary = [
             (id >> 8) & 0xff, (id >> 0) & 0xff,  // ID
             0x01, 0x00,  // RD=1
             0x00, 0x01,  // QDCOUNT=1
             0x00, 0x00,  // ANCOUNT=0
             0x00, 0x00,  // NSCOUNT=0
             0x00, 0x00,  // ARCOUNT=0
         ];

         const qhostname = (hostname[hostname.length - 1] == '.')
                         ? hostname.slice(0, -1) : hostname;

         (new Set(qhostname)).forEach(c => {
             if (!DNS_CHAR.has(c))
                 throw TypeError(`invalid hostname: "${hostname}"`);
         });

         // QNAME
         qhostname.split('.').forEach(label => {
             if (label.length == 0 || label.length > 63)
                 throw TypeError(`invalid hostname: "${hostname}"`)
             binary.push(label.length);
             for (const c of label)
                 binary.push(c.charCodeAt(0));
         });
         binary.push(0);

         binary.push(
             // QTYPE
             (qtype >> 8) & 0xff,
             (qtype >> 0) & 0xff,

             // QCLASS
             0x00, 0x01 // IN
         );

         return new Uint8Array(binary);
     }

     /*
      * DNS 応答を分析する
      * (A, AAAA, CNAME のみ)
      */
     const textDecoder = new TextDecoder();
     function parseDNSResponse(packet) {
         const decoder = textDecoder;

         let pos = 0;
         const readByte = () => packet[pos++];
         const readWord = () => (readByte() << 8) | readByte();
         function readTextN(len) {
             const [beg, end] = [pos, pos += len];
             return decoder.decode(packet.slice(beg, end));
         }
         const readText = () => readTextN(readByte());
         function readLabel() {
             const label = [];
             let stack = null;
             for (;;) {
                 const len = readByte();
                 if (len == 0) {
                     if (stack != null)
                         pos = stack;
                     break;
                 }
                 if (len < 64) {
                     label.push(readTextN(len));
                     continue;
                 }
                 if (len < 192)
                     throw new RangeError();

                 const newpos = ((len & 0x3f) << 8) | readByte();
                 if (stack == null)
                     stack = pos;
                 pos = newpos;
             }
             return label.join('.');
         }
         function readRR() {
             const name = readLabel();
             const rType = readWord();
             const rClass = readWord();
             const ttl = (readWord() << 16) | readWord();
             const rdlength = readWord();
             const rd_beg = pos;
             const rd_end = pos + rdlength;

             const RR = {
                 name: name,
                 resourceType: rType,
                 resourceClass: rClass,
                 ttl: ttl,
                 rdlength: rdlength,
                 rdata: packet.slice(rd_beg, rd_end),
             }

             switch (rType) {
                 case 1: // A
                     {
                         const ipv4 = [];
                         while ((rd_end - pos) >= 4) {
                             const addr = [...Array(4)].map(() => readByte());
                             ipv4.push(addr.join('.'));
                         }
                         RR.ipv4 = ipv4;
                     }
                     break;
                 case 5: // CNAME
                     RR.cname = readLabel();
                     break;
                 case 28: // AAAA
                     {
                         const ipv6 = [];
                         while ((rd_end - pos) >= 16) {
                             const addr = [...Array(8)].map(() => readWord());
                             ipv6.push(addr.map(w => w.toString(16)).join(':'));
                         }
                         RR.ipv6 = ipv6;
                     }
                     break;
             }

             pos = rd_end;
             return RR;
         }

         const header = {}
         header.id = readWord();
         {
             const flag = readByte();
             header.qr = !!(flag & 0x80);
             header.opcode = (flag >> 3) & 0x0f;
             header.aa = !!(flag & 0x04);
             header.tc = !!(flag & 0x02);
             header.rd = !!(flag & 0x01);
         }
         {
             const flag = readByte();
             header.ra = !!(flag & 0x80);
             header.z = (flag >> 4) & 0x07;
             header.rcode = flag & 0x0f;
         }
         header.qdcount = readWord();
         header.ancount = readWord();
         header.nscount = readWord();
         header.arcount = readWord();

         const question = [...Array(header.qdcount)].map(() => ({
             qname: readLabel(),
             qtype: readWord(),
             qclass: readWord(),
         }));
         const answer = [...Array(header.ancount)].map(() => readRR());
         const authority = [...Array(header.nscount)].map(() => readRR());
         const additional = [...Array(header.arcount)].map(() => readRR());
         return { header, question, answer, authority, additional }
     }

     /*
      * ホスト名から IP アドレスを取得する
      * (RFC 8484 : DNS over HTTP)
      */

     const debug = false;
     let dnsQueryID = 0;
     async function doQuery(urlquery, hostname, qtype) {
         try {
             const query_packet = createDNSQuery(++dnsQueryID, qtype, hostname);
             if (debug)
                 console.log('Query:', parseDNSResponse(query_packet));
             const query_base64 = encodeBase64URL(query_packet);
             const res = await fetch(`${urlquery}?dns=${query_base64}`, {
                 method: 'GET',
                 headers: new Headers({
                     'Accept': 'application/dns-message',
                 }),
             });

             const blob = await res.blob();
             const binary = await blob.arrayBuffer();

             if (!res.ok)
                 return {
                     status: `ERROR(${res.status})`,
                     error: textDecoder.decode(binary),
                 }

             const dnsres = parseDNSResponse(new Uint8Array(binary));
             if (debug)
                 console.log('Response:', dnsres);
             const respose = {
                 status: `HTTP=${res.status}, RCODE=${dnsres.header.rcode}`,
                 rcode: dnsres.header.rcode,
                 cname: [
                     dnsres.answer.map(a => a.cname),
                     dnsres.authority.map(a => a.cname),
                     dnsres.additional.map(a => a.cname),
                 ].flat().filter(s => !!s),
                 address: [
                     dnsres.answer.map(a => a.ipv4 ?? a.ipv6),
                     dnsres.authority.map(a => a.ipv4 ?? a.ipv6),
                     dnsres.additional.map(a => a.ipv4 ?? a.ipv6),
                 ].flat().filter(s => s && s.length > 0),
             }
             respose.cname.sort();
             respose.address.sort();
             return respose;
         } catch (e) {
             console.error(e);
             return {status:`ERROR(${e})`}
         }
     }

     async function getIPAddr(urlquery, hostname) {
         const v4 = await doQuery(urlquery, hostname, 1);   // 'A' = 1
         const v6 = await doQuery(urlquery, hostname, 28);  // 'AAAA' = 28
         const status = {
             v4: v4.status,
             v6: v6.status,
         }
         const error = {
             v4: v4.error,
             v6: v6.error,
         }
         const cname = [... new Set([
             v4.cname ?? [],
             v6.cname ?? [],
         ].flat().filter(s => s && s.length > 0))].sort();
         const ipv4 = !v4 ? [] : v4.address ?? [];
         const ipv6 = !v6 ? [] : v6.address ?? [];
         return {status, cname, ipv4, ipv6, error}
     }

     /*
      * UI
      */

     function onClick() {
         const dns = tagDNS.value;
         const host = tagHost.value;

         if (host.length == 0) {
             tagOut.innerText = 'ホスト名がありません';
             tagErrV4.innerHTML = '';
             tagErrV6.innerHTML = '';
             return;
         }

         const indent = '\xa0\xa0\xa0\xa0';
         getIPAddr(dns, host, true).then(res => {
             tagOut.innerText = [
                 `IPv4: ${res.status.v4}\n${indent}${res.ipv4.join('\n' + indent)}`, '',
                 `IPv6: ${res.status.v6}\n${indent}${res.ipv6.join('\n' + indent)}`, '',
                 `CNAME:\n${indent}${res.cname.join('\n' + indent)}`,
             ].join('\n');
             tagErrV4.innerHTML = res.error.v4 ?? '';
             tagErrV6.innerHTML = res.error.v6 ?? '';
         });
     }

     // CodePen 対策
     window.onload = function() {
         document.body.innerHTML = [
             '<table border="1">',
             '<tr>',
             '<th>DoH(URL)</th>',
             '<td><input id="tagDNS" type="text" style="width: 40em;" value="https://dns.google/dns-query"></td>',
             '</tr>',
             '<tr>',
             '<th>ホスト名</th>',
             '<td><input id="tagHost" type="text" style="width: 40em;"></td>',
             '</tr>',
             '<tr>',
             '<th><button onclick="onClick()">取得</button></th>',
             '<td id="tagOut"></td>',
             '</tr>',
             '<tr><th>IPv4: Error</th><td id="tagErrV4"></td></tr>',
             '<tr><th>IPv6: Error</th><td id="tagErrV6"></td></tr>',
             '</table>'
         ].join('');
     }

    </script>
  </body>
</html>
```