写点什么

netty 系列之: 在 netty 中使用 TCP 协议请求 DNS 服务器

作者:程序那些事
  • 2022 年 5 月 24 日
  • 本文字数:5230 字

    阅读完需:约 17 分钟

netty系列之:在netty中使用TCP协议请求DNS服务器

简介

DNS 的全称 domain name system,既然是一个系统就有客户端和服务器之分。一般情况来说我们并不需要感知这个 DNS 客户端的存在,因为我们在浏览器访问某个域名的时候,浏览器作为客户端已经实现了这个工作。


但是有时候我们没有使用浏览器,比如在 netty 环境中,如何构建一个 DNS 请求呢?

DNS 传输协议简介

在 RFC 的规范中,DNS 传输协议有很多种,如下所示:


  • DNS-over-UDP/53 简称"Do53",是使用 UDP 进行 DNS 查询传输的协议。

  • DNS-over-TCP/53 简称"Do53/TCP",是使用 TCP 进行 DNS 查询传输的协议。

  • DNSCrypt,对 DNS 传输协议进行加密的方法。

  • DNS-over-TLS 简称"DoT",使用 TLS 进行 DNS 协议传输。

  • DNS-over-HTTPS 简称"DoH",使用 HTTPS 进行 DNS 协议传输。

  • DNS-over-TOR,使用 VPN 或者 tunnels 连接 DNS。


这些协议都有对应的实现方式,我们先来看下 Do53/TCP,也就是使用 TCP 进行 DNS 协议传输。

DNS 的 IP 地址

先来考虑一下如何在 netty 中使用 Do53/TCP 协议,进行 DNS 查询。


因为 DNS 是客户端和服务器的模式,我们需要做的是构建一个 DNS 客户端,向已知的 DNS 服务器端进行查询。


已知的 DNS 服务器地址有哪些呢?


除了 13 个 root DNS IP 地址以外,还出现了很多免费的公共 DNS 服务器地址,比如我们常用的阿里 DNS,同时提供了 IPv4/IPv6 DNS 和 DoT/DoH 服务。


IPv4: 223.5.5.5
223.6.6.6
IPv6: 2400:3200::1
2400:3200:baba::1
DoH 地址: https://dns.alidns.com/dns-query
DoT 地址: dns.alidns.com
复制代码


再比如百度 DNS,提供了一组 IPv4 和 IPv6 的地址:


IPv4: 180.76.76.76
IPv6: 2400:da00::6666
复制代码


还有 114DNS:


114.114.114.114114.114.115.115
复制代码


当然还有很多其他的公共免费 DNS,这里我选择使用阿里的 IPv4:223.5.5.5 为例。


有了 IP 地址,我们还需要指定 netty 的连接端口号,这里默认的是 53。


然后就是我们要查询的域名了,这里以 www.flydean.com 为例。


你也可以使用你系统中配置的 DNS 解析地址,以 mac 为例,可以通过 nslookup 进行查看本地的 DNS 地址:


nslookup  www.flydean.comServer:    8.8.8.8Address:  8.8.8.8#53
Non-authoritative answer:www.flydean.com canonical name = flydean.com.Name: flydean.comAddress: 47.107.98.187
复制代码

Do53/TCP 在 netty 中的使用

有了 DNS Server 的 IP 地址,接下来我们需要做的就是搭建 netty client,然后向 DNS server 端发送 DNS 查询消息。

搭建 DNS netty client

因为我们进行的是 TCP 连接,所以可以借助于 netty 中的 NIO 操作来实现,也就是说我们需要使用 NioEventLoopGroup 和 NioSocketChannel 来搭建 netty 客户端:


 final String dnsServer = "223.5.5.5";        final int dnsPort = 53;
EventLoopGroup group = new NioEventLoopGroup(); Bootstrap b = new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .handler(new Do53ChannelInitializer());
final Channel ch = b.connect(dnsServer, dnsPort).sync().channel();
复制代码


netty 中的 NIO Socket 底层使用的就是 TCP 协议,所以我们只需要像常用的 netty 客户端服务一样构建客户端即可。


然后调用 Bootstrap 的 connect 方法连接到 DNS 服务器,就建立好了 channel 连接。


这里我们在 handler 中传入了自定义的 Do53ChannelInitializer,我们知道 handler 的作用是对消息进行编码、解码和对消息进行读取。因为目前我们并不知道客户端查询的消息格式,所以 Do53ChannelInitializer 的实现我们在后面再进行详细讲解。

发送 DNS 查询消息

netty 提供了 DNS 消息的封装,所有的 DNS 消息,包括查询和响应都是 DnsMessage 的子类。


每个 DnsMessage 都有一个唯一标记的 ID,还有代表这个 message 类型的 DnsOpCode。


对于 DNS 来说,opCode 有下面这几种:


    public static final DnsOpCode QUERY = new DnsOpCode(0, "QUERY");    public static final DnsOpCode IQUERY = new DnsOpCode(1, "IQUERY");    public static final DnsOpCode STATUS = new DnsOpCode(2, "STATUS");    public static final DnsOpCode NOTIFY = new DnsOpCode(4, "NOTIFY");    public static final DnsOpCode UPDATE = new DnsOpCode(5, "UPDATE");
复制代码


因为每个 DnsMessage 都可能包含 4 个 sections,每个 section 都以 DnsSection 来表示。因为有 4 个 section,所以在 DnsSection 定义了 4 个 section 类型:


    QUESTION,    ANSWER,    AUTHORITY,    ADDITIONAL;
复制代码


每个 section 里面又包含了多个 DnsRecord, DnsRecord 代表的就是 Resource record,简称为 RR,RR 中有一个 CLASS 字段,下面是 DnsRecord 中 CLASS 字段的定义:


    int CLASS_IN = 1;    int CLASS_CSNET = 2;    int CLASS_CHAOS = 3;    int CLASS_HESIOD = 4;    int CLASS_NONE = 254;    int CLASS_ANY = 255;
复制代码


DnsMessage 是 DNS 消息的统一表示,对于查询来说,netty 中提供了一个专门的查询类叫做 DefaultDnsQuery。


先来看下 DefaultDnsQuery 的定义和构造函数:


public class DefaultDnsQuery extends AbstractDnsMessage implements DnsQuery {
public DefaultDnsQuery(int id) { super(id); }
public DefaultDnsQuery(int id, DnsOpCode opCode) { super(id, opCode); }
复制代码


DefaultDnsQuery 的构造函数需要传入 id 和 opCode。


我们可以这样定义一个 DNS 查询:


int randomID = (int) (System.currentTimeMillis() / 1000);            DnsQuery query = new DefaultDnsQuery(randomID, DnsOpCode.QUERY)
复制代码


既然是 QEURY,那么还需要设置 4 个 sections 中的查询 section:


query.setRecord(DnsSection.QUESTION, new DefaultDnsQuestion(queryDomain, DnsRecordType.A));
复制代码


这里调用的是 setRecord 方法向 section 中插入 RR 数据。


这里的 RR 数据使用的是 DefaultDnsQuestion。DefaultDnsQuestion 的构造函数有两个,一个是要查询的 domain name,这里就是"www.flydean.com",另外一个参数是 dns 记录的类型。


dns 记录的类型有很多种,在 netty 中有一个专门的类 DnsRecordType 表示,DnsRecordType 中定义了很多个类型,如下所示:


public class DnsRecordType implements Comparable<DnsRecordType> {    public static final DnsRecordType A = new DnsRecordType(1, "A");    public static final DnsRecordType NS = new DnsRecordType(2, "NS");    public static final DnsRecordType CNAME = new DnsRecordType(5, "CNAME");    public static final DnsRecordType SOA = new DnsRecordType(6, "SOA");    public static final DnsRecordType PTR = new DnsRecordType(12, "PTR");    public static final DnsRecordType MX = new DnsRecordType(15, "MX");    public static final DnsRecordType TXT = new DnsRecordType(16, "TXT");    ...
复制代码


因为类型比较多,我们挑选几个常用的进行讲解。


  • A 类型,是 address 的缩写,用来指定主机名或者域名对应的 ip 地址.

  • NS 类型,是 name server 的缩写,是域名服务器记录,用来指定域名由哪个 DNS 服务器来进行解析。

  • MX 类型,是 mail exchanger 的缩写,是一个邮件交换记录,用来根据邮箱的后缀来定位邮件服务器。

  • CNAME 类型,是 canonical name 的缩写,可以将多个名字映射到同一个主机.

  • TXT 类型,用来表示主机或者域名的说明信息。


以上几个是我们经常会用到的 dns record 类型。


这里我们选择使用 A,用来查询域名对应的主机 IP 地址。


构建好 query 之后,我们就可以使用 netty client 发送 query 指令到 dns 服务器了,具体的代码如下:


            DnsQuery query = new DefaultDnsQuery(randomID, DnsOpCode.QUERY)                    .setRecord(DnsSection.QUESTION, new DefaultDnsQuestion(queryDomain, DnsRecordType.A));            ch.writeAndFlush(query).sync();
复制代码

DNS 查询的消息处理

DNS 的查询消息我们已经发送出去了,接下来就是对消息的处理和解析了。


还记得我们自定义的 Do53ChannelInitializer 吗?看一下它的实现:


class Do53ChannelInitializer extends ChannelInitializer<SocketChannel> {    @Override    protected void initChannel(SocketChannel ch) {        ChannelPipeline p = ch.pipeline();        p.addLast(new TcpDnsQueryEncoder())                .addLast(new TcpDnsResponseDecoder())                .addLast(new Do53ChannelInboundHandler());    }}
复制代码


我们向 pipline 中添加了两个 netty 自带的编码解码器 TcpDnsQueryEncoder 和 TcpDnsResponseDecoder,还有一个自定义用来做消息解析的 Do53ChannelInboundHandler。


因为我们向 channel 中写入的是 DnsQuery,所以需要一个 encoder 将 DnsQuery 编码为 ByteBuf,这里使用的是 netty 提供的 TcpDnsQueryEncoder:


public final class TcpDnsQueryEncoder extends MessageToByteEncoder<DnsQuery> 
复制代码


TcpDnsQueryEncoder 继承自 MessageToByteEncoder,表示将 DnsQuery 编码为 ByteBuf。


看下他的 encode 方法:


    protected void encode(ChannelHandlerContext ctx, DnsQuery msg, ByteBuf out) throws Exception {        out.writerIndex(out.writerIndex() + 2);        this.encoder.encode(msg, out);        out.setShort(0, out.readableBytes() - 2);    }
复制代码


可以看到 TcpDnsQueryEncoder 在 msg 编码之前存储了 msg 的长度信息,所以是一个基于长度的对象编码器。


这里的 encoder 是一个 DnsQueryEncoder 对象。


看一下它的 encoder 方法:


    void encode(DnsQuery query, ByteBuf out) throws Exception {        encodeHeader(query, out);        this.encodeQuestions(query, out);        this.encodeRecords(query, DnsSection.ADDITIONAL, out);    }
复制代码


DnsQueryEncoder 会依次编码 header、questions 和 records。


完成编码之后,我们还需要从 DNS server 的返回中 decode 出 DnsResponse,这里使用的是 netty 自带的 TcpDnsResponseDecoder:


public final class TcpDnsResponseDecoder extends LengthFieldBasedFrameDecoder
复制代码


TcpDnsResponseDecoder 继承自 LengthFieldBasedFrameDecoder,表示数据是以字段长度来进行分割的,这和我们刚刚将的 encoder 的格式类似。


来看下他的 decode 方法:


    protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {        ByteBuf frame = (ByteBuf)super.decode(ctx, in);        if (frame == null) {            return null;        } else {            DnsResponse var4;            try {                var4 = this.responseDecoder.decode(ctx.channel().remoteAddress(), ctx.channel().localAddress(), frame.slice());            } finally {                frame.release();            }            return var4;        }    }
复制代码


decode 方法先调用 LengthFieldBasedFrameDecoder 的 decode 方法将要解码的内容提取出来,然后调用 responseDecoder 的 decode 方法,最终返回 DnsResponse。


这里的 responseDecoder 是一个 DnsResponseDecoder。具体 decoder 的细节这里就不过多阐述了。感兴趣的同学可以自行查阅代码文档。


最后,我们得到了 DnsResponse 对象。


接下来就是自定义的 InboundHandler 对消息进行解析了:


class Do53ChannelInboundHandler extends SimpleChannelInboundHandler<DefaultDnsResponse> 
复制代码


在它的 channelRead0 方法中,我们调用了 readMsg 方法对消息进行处理:


    private static void readMsg(DefaultDnsResponse msg) {        if (msg.count(DnsSection.QUESTION) > 0) {            DnsQuestion question = msg.recordAt(DnsSection.QUESTION, 0);            log.info("question is :{}",question);        }        int i = 0, count = msg.count(DnsSection.ANSWER);        while (i < count) {            DnsRecord record = msg.recordAt(DnsSection.ANSWER, i);            //A记录用来指定主机名或者域名对应的IP地址            if (record.type() == DnsRecordType.A) {                DnsRawRecord raw = (DnsRawRecord) record;                log.info("ip address is: {}",NetUtil.bytesToIpAddress(ByteBufUtil.getBytes(raw.content())));            }            i++;        }    }
复制代码


DefaultDnsResponse 是 DnsResponse 的一个实现,首先判断 msg 中的 QUESTION 个数是否大于零。


如果大于零,则打印出 question 的信息。


然后再解析出 msg 中的 ANSWER 并打印出来。


最后,我们可能得到这样的输出:


INFO  c.f.dnstcp.Do53ChannelInboundHandler - question is :DefaultDnsQuestion(www.flydean.com. IN A)INFO  c.f.dnstcp.Do53ChannelInboundHandler - ip address is: 47.107.98.187
复制代码

总结

以上就是使用 netty 创建 DNS client 进行 TCP 查询的讲解。


本文的代码,大家可以参考:


learn-netty4


更多内容请参考 http://www.flydean.com/54-netty-dns-over-tcp/

最通俗的解读,最深刻的干货,最简洁的教程,众多你不

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!

发布于: 2022 年 05 月 24 日阅读数: 18
用户头像

关注公众号:程序那些事,更多精彩等着你! 2020.06.07 加入

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧,尽在公众号:程序那些事!

评论

发布
暂无评论
netty系列之:在netty中使用TCP协议请求DNS服务器_Java_程序那些事_InfoQ写作社区