在遥远的森林中颠簸的DNS数据包...
L. Kaganov“底部的哈姆雷特”
开发网络应用程序时,有时有必要在本地运行它,但要使用真实域名访问它。 经过验证的标准解决方案是在domains文件中注册域。 该方法的缺点是主机需要域名的明确对应关系,即 不支持星星。 即 如果存在以下形式的域:
dom1.example.com, dom2.example.com, dom3.example.com, ................ domN.example.com,
然后在主机中,您需要全部注册。 在某些情况下,事先不知道第三级域。 有一种愿望(我为自己写的,也许有人会说这很正常),希望通过这样的话:
*.example.com
解决该问题的方法可能是使用您自己的DNS服务器,该服务器将根据指定的逻辑处理请求。 有这样的服务器,它们是完全免费的,并具有方便的图形界面,例如CoreDNS 。 您还可以更改路由器上的DNS记录。 最后,使用xip.io之类的服务,它虽然不是完全成熟的DNS服务器,但是对于某些任务而言却是完美的。 简而言之,存在现成的解决方案,您可以使用,而不必费心。
但是本文介绍了另一种方式-编写自己的自行车,这是创建类似于上面所列工具的起点。 我们将编写我们的DNS代理,它将侦听传入的DNS查询,如果请求的域名在列表中,它将返回指定的IP,如果不是,它将请求更高的DNS服务器并转发接收到的响应,而无需更改请求的程序。
同时,您可以记录请求和收到的响应。 由于每个人都需要DNS,包括浏览器,通讯程序和防病毒软件以及操作系统服务等,因此它可能会提供很多信息。
原理很简单。 在IPv4的网络连接设置中,我们使用正在运行的自写DNS代理将DNS服务器地址更改为计算机的地址(如果不通过网络工作则为127.0.0.1),并在其设置中指定较高DNS服务器的地址。 而且,看来,仅此而已!
我们将不使用标准函数来解析nslookup和nsresolve域名,因此DNS系统设置和hosts文件的内容不会影响程序的运行。 根据情况,它可能有用或无效,您只需要记住这一点即可。 为简单起见,我们将自己限于基本功能本身的实现:
- IP欺骗仅适用于A类(主机地址)和IN类(Internet)的记录
- 欺骗性IP地址(仅版本4)
- 仅通过UDP连接本地传入请求
- 通过UDP或TLS连接到上游DNS服务器
- 如果有多个网络接口,则将在任何一个接口上接受传入的本地请求
- 不支持EDNS
谈到测试项目中几乎没有单元测试。 没错,它们是按照以下原则工作的:我启动了它,如果控制台中显示了一些理智的东西,那么一切都很好,但是如果有异常发生,那么就有问题了。 但是,即使这样笨拙的方法也可以使您成功地定位问题,因此使用Unit。
启动-端口53上的服务器
让我们开始吧。 首先,您需要教会应用程序接受传入的DNS查询。 我们正在编写一个简单的TCP服务器,该服务器仅侦听端口53并记录传入的连接。 在网络连接的属性中,我们编写DNS服务器127.0.0.1的地址,启动应用程序,进入浏览器以查看多个页面-并在控制台中保持静音,浏览器将正常显示该页面。 好了,我们将TCP更改为UDP,我们开始,我们通过浏览器-在浏览器中存在连接错误,一些二进制数据被注入控制台。 因此,系统通过UDP发送请求,我们将在端口53上通过UDP监听传入的连接。 半小时的工作,其中15分钟是谷歌如何在NodeJS上启动TCP和UDP服务器-我们已经解决了该项目的基础任务,该任务确定了未来应用程序的结构。 代码如下:
const dgram = require('dgram'); const server = dgram.createSocket('udp4'); (function() { server.on('error', (err) => { console.log(`server error:\n${err.stack}`); server.close(); }); server.on('message', async (localReq, linfo) => { console.log(localReq);
清单1.接收本地DNS查询所需的最低代码
接下来的一点是阅读消息,以了解是需要返回我们的IP来响应它,还是直接传递它。
DNS消息
DNS消息的结构在RFC-1035中描述。 请求和响应都遵循此结构,并且原则上在消息头中只有一位标志(QR字段)不同。 该消息包括五个部分:
+---------------------+ | Header | +---------------------+ | Question | the question for the name server +---------------------+ | Answer | RRs answering the question +---------------------+ | Authority | RRs pointing toward an authority +---------------------+ | Additional | RRs holding additional information +---------------------+
常规DNS消息结构https://tools.ietf.org/html/rfc1035#section-4.1
DNS消息以固定长度的标头(即所谓的标头部分) 开头 ,该标头包含从1位到两个字节长的字段(因此,标头中的一个字节可以包含多个字段)。 标头以ID字段开头-这是16位请求标识符,响应必须具有相同的ID。 接下来是描述请求类型,请求执行结果以及消息的每个后续部分中的记录数的字段。 长时间描述它们,所以谁在乎-RFC: https : //tools.ietf.org/html/rfc1035#section-4.1.1 。 标头部分始终存在于DNS消息中。
1 1 1 1 1 1 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ID | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ |QR| Opcode |AA|TC|RD|RA| Z | RCODE | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | QDCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ANCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | NSCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ARCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
DNS邮件标头结构https://tools.ietf.org/html/rfc1035#section-4.1.1
问题部分
问题部分包含一个条目,告诉服务器确切需要从服务器中获取什么信息。 从理论上讲,在此类记录的部分中可以有一个或多个,它们的编号在消息头的QDCOUNT字段中指示,并且可以为0、1或更大。 但实际上,“问题”部分只能包含一个条目。 如果“ 问题”部分包含多个记录,并且其中一个记录在服务器上处理请求时会导致错误,则将出现不确定的情况。 尽管服务器将在响应消息的RCODE字段中返回错误代码,但它无法指示何时处理记录问题的记录,但规范并未对此进行描述。 记录也没有包含指示错误及其类型的字段。 因此,有一个协议(未记录),根据该协议, Question部分只能包含一个记录,并且QDCOUNT字段的值为1。如果它仍然包含Question中的多个记录,那么还不清楚如何在服务器端处理请求。 有人建议返回带有请求错误的消息。 并且,例如,Google DNS仅处理“ 问题”部分中的第一条记录,而只是忽略其余部分。 显然,这仍由DNS服务的开发人员自行决定。
在来自服务器的响应DNS消息中,“ 问题”部分也存在,并且应完全复制请求的“ 问题” (为了避免冲突,以防一个ID字段不够用)。
问题部分中的唯一条目包含以下字段:QNAME(域名),QTYPE(类型),QCLASS(类)。 QTYPE和QCLASS是双字节数字,指示请求的类型和类别。 可能的类型和类在RFC-1035 https://tools.ietf.org/html/rfc1035#section-3.2中进行了描述,在那里一切都清楚了。 但是,在记录域名的方法上,我们将在“记录域名的格式”部分中详细介绍。
对于查询,DNS消息通常以“ 问题”部分结尾,有时可以在“ 附加”部分之后。
如果在服务器上处理请求时发生错误(例如,错误地生成了传入请求),则响应消息也将以Question或Additional部分结尾,并且响应消息头的RCODE字段将包含错误代码。
答案 , 权限和其他部分
以下各节分别是Answer , Authority和Additional ( Answer和Authority只包含在响应DNS消息中, Additional可能会出现在请求和响应中)。 它们是可选的,即 根据要求,它们中的任何一个都可以存在或不存在。 这些部分具有相同的结构,并包含所谓的“资源记录”( 资源记录或RR)格式的信息。 形象地说,这些部分中的每个部分都是资源记录的数组,而记录是具有字段的对象。 每个部分可以包含一个或多个记录,它们的编号在消息标题的相应字段中分别指示(分别为ANCOUNT,NSCOUNT和ARCOUNT)。 例如,对域“ google.com”的IP请求将返回多个IP地址,因此“ 答案”部分还将有多个条目,每个地址一个。 如果该部分不存在,则相应的标题字段包含0。
每个资源记录 (RR)以包含域名的NAME字段开头。 该字段的格式与“ 问题”部分的“ QNAME”字段相同。
NAME旁边是TYPE(记录类型)和CLASS(其类)字段,两个字段均为16位数字,表示记录的类型和类。 这也类似于“ 问题”部分,不同之处在于其QTYPE和QCLASS可以具有与TYPE和CLASS相同的所有值,以及它们自己唯一的其他值。 也就是说,用枯燥的科学语言,QTYPE和QCLASS值的集合是TYPE和CLASS值的超集。 在https://tools.ietf.org/html/rfc1035#section-3.2.2上了解有关差异的更多信息。
其余字段为:
- TTL是一个32位数字,指示记录的持续时间(以秒为单位)。
- RDLENGTH是一个16位数字,指示下一个RDATA字段的长度(以字节为单位)。
- RDATA实际上是有效载荷,其格式取决于记录的类型。 例如,对于类型A(主机地址)和类IN(互联网)的记录,它们是4个字节,代表一个IPv4地址。
如果QNAME和NAME字段以及RDATA字段的域名记录格式是相同的(如果它是CNAME,MX,NS或其他假定域名为域名的类记录)。
域名是一系列标签(名称的各个部分,子域-这是原始标签 ,我没有找到更好的翻译)。 标签是一个长度为一个字节的字节,其中包含一个数字-标签内容的长度(以字节为单位),后跟一个指定长度的字节序列。 标签一个接一个地跟随,直到找到一个长度为0的字节为止,第一个标签的长度立即为零,这表示根域(Root Domain)的域名为空(有时写为“”)。
在早期版本的DNS中,标签中的字节可以具有(0到255)之间的任何值。 有一些紧急建议的规则:标签以字母开头,以字母或数字结尾,并且仅包含7位ASCII编码的字母,数字或连字符,且最高有效位为零。 当前的EDNS规范已经明确要求遵守这些规则,并且没有偏差。
长度字节的两个最高有效位用作标签类型属性。 如果它们为零( 0b00xxxxxx ),则这是一个常规标签,长度字节的其余位指示其组成中包含的数据字节数。 标签的最大长度为63个字符。 二进制编码中的63只是0b00111111 。
如果两个最高有效位分别为0和1( 0b01xxxxxx ),则这是EDNS标准的扩展类型标签( https://tools.ietf.org/html/rfc2671#section-3.1 ),该标签于2019年2月1日发布。 低六位将包含标签值。 我们不在本文中讨论EDNS,但是知道这也会发生是很有用的。
保留等于1和0( 0b10xxxxxx )的两个最高有效位的组合,以供将来使用。
如果两个高位都等于1( 0b11xxxxxx ),则意味着域名被压缩( compression ),我们将在下面详细介绍。
域名压缩
因此,如果一个字节的长度具有等于1( 0b11xxxxxx )的两个高位,则表明域名压缩。 压缩用于使消息更短,更简洁。 当使用UDP处理DNS消息的总长度限制为512字节时(尤其是这是旧标准,请参阅https://tools.ietf.org/html/rfc1035#section-2.3.4大小限制 ,新的EDNS)允许发送UPD消息及更长的消息)。 该过程的本质是,如果DNS消息包含具有相同顶级子域的域名(例如, mail.yandex.ru和yandex.ru ),则与其重新指定整个域名, 不如从DNS消息中提取字节数继续阅读域名。 这可以是DNS消息的任何字节,不仅可以在当前记录或部分中,还可以是域标签长度的一个字节。 您不能参考标记的中间。 假设消息中有一个mail.yandex.ru域,那么在压缩的帮助下,还可以指定yandex.ru , ru和root“”域(当然,根无需压缩就更容易编写,但是从技术上讲,可以通过压缩来做到这一点),并且在这里使ndex.ru无法正常工作。 同样,所有派生域名都将以根域结尾,也就是说,写,例如mail.yandex也将失败。
域名可以:
- 被完整记录而无需压缩,
- 从使用压缩的地方开始
- 从一个或多个未压缩的标签开始,然后切换到压缩,
- 为空(对于根域)。
例如,我们正在编译DNS消息,并且已经在其中遇到了名称“ dom3.example.com”,现在我们需要指定“ dom4.dom3.example.com”。 在这种情况下,您可以记录“ dom4”部分而不进行压缩,然后切换到压缩,即添加指向“ dom3.example.com”的链接。 反之亦然,如果以前曾遇到名称“ dom4.dom3.example.com”,则要表示“ dom3.example.com”,您可以通过引用其中的标签“ dom3”立即使用压缩。 正如我们已经说过的,我们不能通过压缩来指示“ dom4.dom3”的一部分,因为名称必须以顶层部分结尾。 如果您突然需要从中间指定片段,则无需压缩即可简单地指示它们。
为简单起见,我们的程序不知道如何通过压缩来写域名,它只能读取。 该标准允许这样做,阅读必须一定要实现,写作是可选的。 从技术上讲,读取是这样实现的:如果一个字节长度的两个最高有效位包含1,则我们读取紧随其后的字节,并将这两个字节视为16位无符号整数,并按Big Endian位的顺序进行处理。 我们丢弃两个最高有效位(包含1),读取结果14位数字,并继续从DNS消息中与该数字对应的数字的字节中读取域名。
域名读取功能的代码如下:
function readDomainName (buf, startOffset, objReturnValue = {}) { let currentByteIndex = startOffset;
清单2.从DNS查询中读取域名
从二进制缓冲区读取DNS记录的功能的完整代码:
清单3.从二进制缓冲区读取DNS记录 function parseDnsMessageBytes (buf) { const msgFields = {};
清单3.从二进制缓冲区读取DNS记录
, . , , , . , DNS-, , . , .
, - server.on("message", () => {})
1. :
4. DNS- server.on('message', async (localReq, linfo) => { const dnsRequest = functions.parseDnsMessageBytes(localReq); const question = dnsRequest.questions[0];
清单4.处理传入的本地DNS查询
TLS
DNS-. , DNS- TLS (HTTPS ). DNS- TLS TCP, , TLS . TCP, RFC-7766 DNS Transport over TCP ( https://tools.ietf.org/html/rfc7766 ). , : TLS, TCP ( , DNS TCP, TLS- TCP-, ).
TLS-
TLS- , , . , TLS-, . RFC-7858 - :
In order to amortize TCP and TLS connection setup costs, clients and servers SHOULD NOT immediately close a connection after each response. Instead, clients and servers SHOULD reuse existing connections for subsequent queries as long as they have sufficient resources. In some cases, this means that clients and servers may need to keep idle connections open for some amount of time. () https://tools.ietf.org/html/rfc7858#section-3.4
, TLS-, , , , , . , 30 , , , DNS-. 30 ~ ~ , 15 60 , . , . - .
TLS- NodeJS. , TLS- :
const tls = require('tls'); const TLS_SOCKET_IDLE_TIMEOUT = 30000;
5. , TLS-
DNS-over-TLS , Google DNS. , socket = tls.connect(connectionOptions, () => {})
. NodeJS: https://nodejs.org/api/tls.html#tls_tls_connect_options_callback , .
TLS- :
const options = { port: config.upstreamDnsTlsPort,
6. TLS-
, TCP-. TCP/TLS- DNS-, , , , . TCP ( TLS), DNS- 512 , UDP (, EDNS UDP ). , DNS- UDP, . onData() 6.
const onData = (data) => {
7. TLS- DNS- 6
DNS-
, , . , ID QNAME, QTYPE QCLASS Question :
Since pipelined responses can arrive out of order, clients MUST match responses to outstanding queries on the same TLS connection using the Message ID. If the response contains a Question Section, the client MUST match the QNAME, QCLASS, and QTYPE fields. () https://tools.ietf.org/html/rfc7858#section-3.3
, , , ID Question ( , ).
UDP (. 4), , -, , UDP- . , DNS-, . , -. , , UDP- -. , , .
TLS, . (IP ), , .
IP "-". , , , DNS-. , , IP , . 7:
8. 7
TLS-:
9. DNS- TLS- ( . 4)
, , . JSON, , NodeJS JSON- . JSON — , . , JSON- "comment" ( ) . , , , , . , , . , - , , NodeJS. , , . , , ; , . , - .
10. const path = require('path'); const fs = require('fs'); const CONFIG_FILE_PATH = path.resolve('./config.json'); function Module () {
清单10.阅读器和配置更新模块
合计
DNS- NodeJS, npm . , , , , .
GitHub
: