手把手教你在netty中使用TCP協議請求DNS伺服器

flydean發表於2022-07-18

簡介

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.114
114.114.115.115

當然還有很多其他的公共免費DNS,這裡我選擇使用阿里的IPv4:223.5.5.5為例。

有了IP地址,我們還需要指定netty的連線埠號,這裡預設的是53。

然後就是我們要查詢的域名了,這裡以www.flydean.com為例。

你也可以使用你係統中配置的DNS解析地址,以mac為例,可以通過nslookup進行檢視本地的DNS地址:

nslookup  www.flydean.com
Server:		8.8.8.8
Address:	8.8.8.8#53

Non-authoritative answer:
www.flydean.com	canonical name = flydean.com.
Name:	flydean.com
Address: 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/

最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不

歡迎關注我的公眾號:「程式那些事」,懂技術,更懂你!

相關文章