DoH, dns over https,使用https来传输dns。

问题

在使用doh的过程中,碰到一个问题,就是使用不同的dns服务商,得到的结果不一样。有的返回的结果能用,有的返回的结果不能用。还有一点,就是虽然有edns_client_subnet这个提议,可以根据用户的ip,来返回更快速的ip,但是不是所有的dns服务商都支持。
因此,使用的过程中,经常需要手动切换浏览器的doh url,因为不切换的话,有些dns服务商返回的地址访问非常慢。
本文就是基于这个问题的一个临时方案。

思路

首先想到了类似proxy的方案,是否能根据不同的域名走不同的dns服务商?
浏览器扩展并没有提供这样的接口。

doh proxy

网络上有doh proxy,即使用服务端,获取dns请求之后,做一个转发。

  1. 第一种方法是proxy使用支持edns_client_subnet的dns server,发现一个问题,转发之后,alidns返回的是海外ip,说明直接转发不行。
  2. 在转发的过程中增加edns_client_subnet,这样就可以获取到更接近的ip.

于是基于第2种方案,不过采取的是另一种实现方式。

方案

本文中的方案,是将浏览器的doh请求,分析之后,访问dns服务商的json接口,通过增加edns_client_subnet,来获取更快速的ip地址。直接再将获取的ip地址组装成dns rr返回给浏览器。

dns 请求和响应分析

通过查看rfc 8484协议,我们知道get和post请求发送的数据格式是一样的,get因为多了base64,数据包更大了。

这里我们首先以cloudflare doh页面的例子来进行学习。

POST 请求,www.example.com A记录

:method = POST
:scheme = https
:authority = cloudflare-dns.com
:path = /dns-query
accept = application/dns-message
content-type = application/dns-message
content-length = 33

<33 bytes represented by the following hex encoding>
00 00 01 00 00 01 00 00  00 00 00 00 03 77 77 77
07 65 78 61 6d 70 6c 65  03 63 6f 6d 00 00 01 00
01

首先我们根据Header section format对header格式进行解读

00 00 ID,在DoH中,设置为0,因为不需要对应
01 00 Flags
00 01 QDCOUNT
00 00 ANCOUNT
00 00 NSCOUNT
00 00 ARCOUNT
// 之后是question部分,label表示长度或者指针,长度则后面的长度为域名信息,指针这里暂且不表
03 77 77 77 label 3,内容www
07 65 78 61 6d 70 6c 65 label 7,内容example
03 63 6f 6d label 3, 内网com
00 QNAME结束
00 01 QTYPE ,1表示A记录
00 01 QCLASS,1表示,IN,即Internet

看完了请求,我们再来看响应包

:status = 200
content-type = application/dns-message
content-length = 64
cache-control = max-age=128

<64 bytes represented by the following hex encoding>
00 00 81 80 00 01 00 01  00 00 00 00 03 77 77 77
07 65 78 61 6d 70 6c 65  03 63 6f 6d 00 00 01 00
01 03 77 77 77 07 65 78  61 6d 70 6c 65 03 63 6f
6d 00 00 01 00 01 00 00  00 80 00 04 C0 00 02 01
//头部分格式是一致的
00 00 ID
81 80 Flags
00 01 QDCOUNT //返回包中是包含请求询问的内容的
00 01 ANCOUNT //有一个Answer结果
00 00
00 00
03 77 77 77 //这一部分和请求一样
07 65 78 61 6d 70 6c 65
03 63 6f 6d
00
00 01 QTYPE ,1表示A记录
00 01
//接下来是Resource record
03 77 77 77 //label部分,不多说
07 65 78 61 6d 70 6c 65
03 63 6f 6d
00 NAME结束
00 01 TYPE,1表示A记录
00 01 CLASS
00 00  00 80 TTL,time to live,过期时间
00 04 RDLENGTH,长度4,表示接下来的rdata长度为4个八比特
C0 00 02 01 RDATA,192 0 2 1,即192.0.2.1,//终于解读完了

cf给了个测试语句,可以用来测试你的doh proxy

echo -n 'q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' | base64 --decode | curl --header 'content-type: application/dns-message' --data-binary @- https://cloudflare-dns.com/dns-query --output - | hexdump

我们分析完了cf提供的例子,再来看看rfc8484给的例子

The same DNS query for "www.example.com", using the POST method would
   be:

   :method = POST
   :scheme = https
   :authority = dnsserver.example.net
   :path = /dns-query
   accept = application/dns-message
   content-type = application/dns-message
   content-length = 33

   <33 bytes represented by the following hex encoding>
   00 00 01 00 00 01 00 00  00 00 00 00 03 77 77 77
   07 65 78 61 6d 70 6c 65  03 63 6f 6d 00 00 01 00
   01

我们可以看到,和cf的例子是一样的

This is an example response for a query for the IN AAAA records for
   "www.example.com" with recursion turned on.  The response bears one
   answer record with an address of 2001:db8:abcd:12:1:2:3:4 and a TTL
   of 3709 seconds.

   :status = 200
   content-type = application/dns-message
   content-length = 61
   cache-control = max-age=3709

   <61 bytes represented by the following hex encoding>
   00 00 81 80 00 01 00 01  00 00 00 00 03 77 77 77
   07 65 78 61 6d 70 6c 65  03 63 6f 6d 00 00 1c 00
   01 c0 0c 00 1c 00 01 00  00 0e 7d 00 10 20 01 0d
   b8 ab cd 00 12 00 01 00  02 00 03 00 04

但是这里的返回包和之前的不一样,刚开始在这里搞了很久,后来搜索之后,才知道是dns label 压缩

c0 0c, 变成二进制,11000000 00000011,当第一个八bit的前两位为11时,表示这里是一个压缩的指针,接下来的14bit为指针,这里为0x0c,我们可以看到0x0c这里刚好指向的就是Question部分的第一个位置,所以这里最终算出来就是www.example.com,是不是就是question。当然这里关于label指针还分几种情况,想要了解的可以看看上面的链接。

这里我们了解了dns的request和response,我们现在就来实现我们相应的功能doh worker

由于要处理字节流,我们需要一些javascript知识。可以参考文末的Mozila的链接。
首先是ArrayBuffer,即字节数组,ArrayBuffer无法直接读写,我们需要使用TypedArray或者DataView来进行访问。
我们结合关键代码来解读。


const arrayBuffer = await request.arrayBuffer();
//首先我们拿到请求的ArrayBuffer,
const respArrayBuffer = new ArrayBuffer(0x3433) //arrayBuffer.byteLength is 0x3333, we need to be bigger than that
//之后我们申请一个ArrayBuffer,用来放返回请求,这里我们做了一个取巧的方式,即把请求直接复制到返回里面,所以这里我们需要设置一个大小合适的数组

const uint8Response = new Uint8Array(respArrayBuffer);
//我们使用Uint8Array来访问返回ArrayBuffer
const view = new DataView(respArrayBuffer)
//同时我们使用DataView来访问ArrayBuffer,因为不通类提供的函数不一样,所以我们要使用不同的类来操作
uint8Response.set(new Uint8Array(arrayBuffer.slice(0, arrayBuffer.byteLength)),0) // copy the request
// 这里使用Uint8Array的set函数,将请求包arrayBuffer复制到响应包中

view.setUint16(2,0x8180) //set flag
//设置返回包的Flags为0x8180,使用DataView,我们不需要考虑endian
let position = 12 //the position of the first question,0x0c,第一个question的位置
let length = view.getUint8(position++) //接下来是读label,没有考虑label压缩的情况
let result = ""
let decoder = new TextDecoder('utf-8');

// read the first label, we didn't deal with the message compression https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.4
// if you want to use it in production, you should consider message compression
// and we only deal with the first question
while (length != 0) {
    result += decoder.decode(uint8Response.slice(position, position+length))+'.';
    position += length;
    length = view.getUint8(position++);
}
// get the first domain
let curDomain = result //拿到域名信息
let curQtype = view.getUint16(position) //拿到Type信息

position += 2
position += 2

// we only deal with the matched domains and it must be a A query, otherwise justs proxy the request
// 我们只处理针对特定域名的a记录查询
if(endsWithAny(curDomain,osdomains) && (curQtype == 1)){
	let userip = request.headers.get('x-real-ip')
	let edns_ip = userip.substring(0,userip.lastIndexOf('.')) + '.0/24'
	//获取ip,使用alidns的json api来获取记录,同时使用了edns_client_subnet
  //we use an doh provider which support edns
  let api = 'https://dns.alidns.com/resolve?name='+curDomain+'&type=1&short=1&edns_client_subnet='+edns_ip
 
	return await fetch(api)
  .then(resp => {
    // 检查响应状态
    if (resp.ok) {
      // 解析返回的 JSON 数据
      //return new Response(resp, {headers: { "Content-Type": "application/dns-message" }})
      return resp.json();
    } else {
      throw new Error(`Failed to fetch data, status: ${resp.status}`);
    }
  })
  .then(data => {
  	//设置an的数量
  view.setUint16(6,data.length) // set ANCOUNT 
 
	for (let item of data) {
      view.setUint16(position,0xc00c) //name,这里我们直接使用了label指针
      position += 2
      view.setUint16(position,0x0001) //type
      position += 2
      view.setUint16(position,0x0001) //class
      position += 2
      view.setUint32(position,300) //ttl
      position += 4
      view.setUint16(position,4) //length(A) = 4
      position += 2
      uint8Response.set(new Uint8Array(item.split('.').map(part=>parseInt(part))) ,position) // set the resolved ip,设置ip
      position += 4
		}
    return new Response(respArrayBuffer.slice(0,position), {headers: { "Content-Type": "application/dns-message" }})
  })
  .catch(error => {
    // 处理错误
    console.error(error);
  });
}
else{
  return await fetch(doh, {
            method: 'POST',
            headers: {
                'Accept': contype,
                'Content-Type': contype,
            },
            body: arrayBuffer,
        });
}

其他

出于好奇,我们也去chromium里面找一找dns相关的代码 https://github.com/chromium/chromium/blob/main/net/dns/dns_query.cc#L107

// DNS query consists of a 12-byte header followed by a question section.
// For details, see RFC 1035 section 4.1.1.  This header template sets RD
// bit, which directs the name server to pursue query recursively, and sets
// the QDCOUNT to 1, meaning the question section has a single entry.

这里我们可以看到,一般情况下,query里面的qdcount为1,即一般一个请求里面查询一个域名。 https://github.com/chromium/chromium/blob/main/net/dns/dns_query.cc#L131

header->qdcount = base::HostToNet16(1);

https://github.com/chromium/chromium/blob/main/net/dns/dns_query.cc#L192

  if (header.qdcount != 1) {
    VLOG(1) << "Not supporting parsing a DNS query with multiple (or zero) "
               "questions.";
    return false;
  }

interesting, so chromium will only parse qdcount == 1 case. https://github.com/chromium/chromium/blob/main/net/dns/dns_query.cc#L270

 while (label_length) {
    if (out->size() + 1 + label_length > dns_protocol::kMaxNameLength) {
      return false;
    }

    out->push_back(static_cast<char>(label_length));

    std::optional<base::span<const uint8_t>> label = reader->Read(label_length);
    if (!label) {
      return false;
    }
    out->append(base::as_string_view(*label));

    if (!reader->ReadU8BigEndian(label_length)) {
      return false;
    }
  }

这里我们没有看到label压缩,难道不处理?但是我们使用了压缩是可以访问的呀?it didn’t deal with the compression?

https://github.com/chromium/chromium/blob/main/net/dns/dns_response.cc#L194

 for (;;) {
    // The first two bits of the length give the type of the length. It's
    // either a direct length or a pointer to the remainder of the name.
    switch (packet_[offset] & dns_protocol::kLabelMask) {

在dns_response中,我们看到了对压缩的处理。

支持多个doh url,如果不带dns参数,则使用post请求,https://www.chromium.org/developers/dns-over-https/

Chromium users (or administrators of managed deployments) can specify a custom configuration as a DoH URI template. If the template includes a dns variable, Chromium will issue DoH requests using the GET HTTP method; otherwise it will use POST. Users can also enter multiple templates separated by whitespace, improving reliability if one DoH server fails. 

内置了一些doh的url https://github.com/chromium/chromium/blob/main/net/dns/public/doh_provider_entry.cc#L69 DohProviderEntry::List

总结

DNS没有想象中的那么难,也没有想象中的那么简单。要实现一个完整版本的功能,还是很复杂的。本文只是针对其中的一部分场景做了实现,并不是完全体。不过呢,能用,哈哈。出bug了再进行升级。

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/DataView https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-wireformat/ doh json api,支持edns_client_subnet https://www.alidns.com/knowledge?type=SETTING_DOCS#company_json