java构建TCP/IP协议:代码实现DNS解析协议

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/tyler_download/article/details/90039048

本节,我们基于上一节理论的基础上,用代码实现DNS数据包的发送和解析。这里有两点需要重复,一是我们将使用DNS的递归式传输模式,也就是消息的发送如下图:

屏幕快照 2019-04-23 上午10.08.36.png

也就是我们将在数据包中的特定数据段内设置标志位,要求第一台域名解析服务器帮我们实现所有的查询流程,然后把最终结果返回给我们,这样我们可以省却多种数据交互和解析流程,一般而言第一台域名解析服务器都是路由器。

第二个值得我们了解的要点是DNS数据包的基本格式:
屏幕快照 2019-04-23 上午10.39.59.png
它包括固定的头部,以及相应的消息体部分。由于头部内容固定不变,因此我们可以在代码实现中写死,它的基本组成结构如下:

屏幕快照 2019-04-23 上午11.29.08.png

重要的是有两个可变的数据部分需要我们掌握,一个是Question数据格式,它包含了客户端向服务器请求的内容格式,它的组成如下:

屏幕快照 2019-04-23 上午11.33.19.png

当我们想要解析某个域名对应的IP时,我们需要按照上面的结构组织信息发布给服务器,服务器顺利解读后会给我们发送如下格式的应答信息:

屏幕快照 2019-04-23 下午4.02.28.png

由此我们代码的目的是构造包头,然后将要查询的域名信息按照上面给出的Question数据格式组织好发送给路由器并等待其回复,拿到回复数据包之后,我们再按照上头anwser resource格式解析服务器返回的数据。

接下来让我们看看代码实现:

package Application;

import java.nio.ByteBuffer;
import java.util.Random;

public class DNSApplication extends Application {
	private byte[] resove_server_ip = null;
	private String domainName = "";
	private byte[] dnsHeader = null;
	private int transition_id = 0;
	
    public DNSApplication( byte[] destIP, String domainName) {
    	this.resove_server_ip = destIP;
    	this.domainName = domainName;
    	Random rand = new Random();
    	transition_id = rand.nextInt();
    	
    	constructDNSPacketHeader();
    }
    
    private void constructDNSPacketHeader() {
    	/*
    	 * 构造DNS数据包包头,总共12字节
    	 */
    	byte[] header = new byte[12];
    	ByteBuffer buffer = ByteBuffer.wrap(header);
    	//2字节的会话id
    	buffer.putShort((short)transition_id);
    	//接下来是2字节的操作码,不同的比特位有相应含义
    	short opCode = 0;
    	/*
    	 * 如果是查询数据包,第0个比特位要将最低位设置为0,接下来的4个比特位表示查询类型,如果是查询ip则设置为0,
    	 * 第5个比特位由服务器在回复数据包中设置,用于表明信息是它拥有的还是从其他服务器查询而来,
    	 * 第6个比特位表示消息是否有分割,有的话设置为1,由于我们使用UDP,因此消息不会有分割。
    	 * 第7个比特位表示是否使用递归式查询请求,我们设置成1表示使用递归式查询,
    	 * 第8个比特位由服务器返回时设置,表示它是否接受递归式查询
    	 * 第9,10,11,3个比特位必须保留为0,
    	 * 最后四个比特由服务器回复数据包设置,0表示正常返回数据,1表示请求数据格式错误,2表示服务器出问题,3表示不存在给定域名等等
    	 * 我们发送数据包时只要将第7个比特位设置成1即可
    	 */
    	opCode = (short) (opCode | (1 << 7));
    	buffer.putShort(opCode);
    	//接下来是2字节的question count,由于我们只有1个请求,因此它设置成1
    	short questionCount = 1;
    	buffer.putShort(questionCount);
    	//剩下的默认设置成0
    	short answerRRCount = 0;
    	buffer.putShort(answerRRCount);
    	short authorityRRCount = 0;
    	buffer.putShort(authorityRRCount);
    	short additionalRRCount = 0;
    	buffer.putShort(additionalRRCount);
    	this.dnsHeader = buffer.array();
    }
}

上面代码中,函数constructDNSPacketHeader完成了查询数据包头部数据的组装,接下来我们我们实现Question数据部分的组装:

 private void constructDNSPacketQuestion() {
    	/*
    	 * 构造DNS数据包中包含域名的查询数据结构
    	 * 首先是要查询的域名,它的结构是是:字符个数+是对应字符,
    	 * 例如域名字符串pan.baidu.com对应的内容为
    	 * 3pan[5]baidu[3]com也就是把‘.'换成它后面跟着的字母个数
    	 */
    	//根据.将域名分割成多个部分,第一个1用于记录"pan"的长度,第二个1用0表示字符串结束
    	dnsQuestion = new byte[1 + 1 + domainName.length() + QUESTION_TYPE_LENGTH + QUESTION_CLASS_LENGTH];
    	String[] domainParts = domainName.split("\\.");
    	ByteBuffer buffer = ByteBuffer.wrap(dnsQuestion);
    	for (int i = 0; i < domainParts.length; i++) {
    		//先填写字符个数
    		buffer.put((byte)domainParts[i].length());
    		//填写字符
    		for(int k = 0; k < domainParts[i].length(); k++) {
    			buffer.put((byte) domainParts[i].charAt(k));
    		}
    	}
    	//表示域名字符串结束
    	byte end = 0;
    	buffer.put(end);
    	//填写查询问题的类型和级别
    	buffer.putShort(QUESTION_TYPE_A);
    	buffer.putShort(QUESTION_CLASS);
    }

上面代码根据我们前面描述的Question数据结构,将要查询的域名字符串封装起来发送给服务器进行解析。完成两部分关键数据的组装后,我们就可以将其组合成一个完整的DNS数据包发送出去:

public void queryDomain() {
    	//向服务器发送域名查询请求数据包
    	byte[] dnsPacketBuffer = new byte[dnsHeader.length + dnsQuestion.length];
    	ByteBuffer buffer = ByteBuffer.wrap(dnsPacketBuffer);
    	buffer.put(dnsHeader);
    	buffer.put(dnsQuestion);
    	
    	byte[] udpHeader = createUDPHeader(dnsPacketBuffer);
    	byte[] ipHeader = createIP4Header(udpHeader.length);
    	
    	byte[] dnsPacket = new byte[udpHeader.length + ipHeader.length];
    	buffer = ByteBuffer.wrap(dnsPacket);
    	buffer.put(ipHeader);
    	buffer.put(udpHeader);
    	//将消息发送给路由器
    	try {
			ProtocolManager.getInstance().sendData(dnsPacket, resove_server_ip);
		} catch (Exception e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
    }

我们在前面章节中已经多次展示过UDP包头和IP包头的组装,在这里我们不再将其代码罗列出来,当上面代码完成后执行时,我们通过wireshark监控可以发现,程序顺利构造了对应的DNS数据包发送,同时受到了服务器的回复数据包:

屏幕快照 2019-05-09 上午9.04.19.png

接下来我们要做的是解析服务器回复的数据包,解析代码如下:

public void handleData(HashMap<String, Object> headerInfo) {
    	/*
    	 * 解读服务器回发的数据包,首先读取头2字节判断transition_id是否与我们发送时使用的一致
    	 */
    	byte[] data = (byte[])headerInfo.get("data");
    	if (data == null) {
    		System.out.println("empty data");
    		return;
    	}
    	
    	ByteBuffer buffer = ByteBuffer.wrap(data);
    	short transionID = buffer.getShort();
    	if (transionID != this.transition_id) {
    		System.out.println("transition id different!");
    		return;
    	}
    	
    	//读取2字节flag各个比特位的含义
    	short flag = buffer.getShort();
    	readFlags(flag);
    	//接下来2字节表示请求的数量
    	short questionCount = buffer.getShort();
    	System.out.println("client send " + questionCount + " requests");
    	//接下来的2字节表示服务器回复信息的数量
    	short answerCount = buffer.getShort();
    	System.out.println("server return " + answerCount + " answers");
    	//接下来2字节表示数据拥有属性信息的数量
    	short authorityCount = buffer.getShort();
    	System.out.println("server return " + authorityCount + " authority resources");
    	//接下来2字节表示附加信息的数量
    	short additionalInfoCount = buffer.getShort();
    	System.out.println("serve return " + additionalInfoCount + " additional infos");
    	
    	//回复数据包会将请求数据原封不动的复制,所以接下来我们先处理question数据结构
    	readQuestions(questionCount, buffer);
    	
    	//读取服务器回复信息
    	readAnswers(answerCount, buffer);
    }

一旦接收到服务器回发的数据包,上面函数就会被调用,首先它解析包头,看看会话id是否与我们发出数据包的id相匹配,然后读取余下内容。由于服务器回复的数据中包含了请求数据包发送的Question数据部分,因此我们也进行相应解读:

 private void readQuestions(int count, ByteBuffer buffer) {
    	for (int i = 0; i < count; i++) {
    		readStringContent(buffer);
    		//查询问题的类型
    		short  questionType = buffer.getShort();
    		if (questionType == QUESTION_TYPE_A) {
    			System.out.println("request ip for given domain name");
    		}
    		//查询问题的级别
    		short questionClass = buffer.getShort();
    		System.out.println("the class of the request is " + questionClass);
    	}
    }

 private void readStringContent(ByteBuffer buffer) {
    	byte charCnt = buffer.get();
    	while(charCnt > 0) {
    		//输出字符
    		for (int i = 0; i < charCnt; i++) {
    			System.out.print((char)buffer.get());
    		}
    		charCnt = buffer.get();
    		if (charCnt != 0) {
    			System.out.print(".");
    		}
    	}
    	
    	System.out.println("\n");
    }

这里需要注意的是解析域名字符串的格式,它是[数字][字符]格式,因此代码读取时首先获得字符的个数,然后再读取相应字符。接下来我们看看读取Answer Resource Record的代码实现,该数据结构的解析稍微复杂一些:

private void readAnswers(int count, ByteBuffer buffer) {
    	/*
    	 * 回复信息的格式如下:
    	 * 第一个字段是name,它的格式如同请求数据中的域名字符串
    	 * 第二个字段是类型,2字节
    	 * 第三字段是级别,2字节
    	 * 第4个字段是Time to live, 4字节,表示该信息可以缓存多久
    	 * 第5个字段是数据内容长度,2字节
    	 * 第6个字段是内如数组,长度如同第5个字段所示
    	 */
    	
    	/*
    	 * 在读取第name字段时,要注意它是否使用了压缩方式,如果是那么该字段的第一个字节就一定大于等于192,也就是
    	 * 它会把第一个字节的最高2比特设置成11,接下来的1字节表示数据在dns数据段中的偏移
    	 */
    	for (int i = 0; i < count; i++) {
    		System.out.println("Name content in answer filed is: ");
        	if (isNameCompression(buffer.get())) {
        		int offset = (int)buffer.get();
        		byte[] array = buffer.array();
        		ByteBuffer dup_buffer = ByteBuffer.wrap(array);
        		//从指定偏移处读取字符串内容
        		dup_buffer.position(offset);
        		readStringContent(dup_buffer);
        	} else {
        		readStringContent(buffer);
        	}
        	
        	short type = buffer.getShort();
        	System.out.println("answer type is : " + type);
        	//接下来2字节对应type
        	if (type == DNS_ANSWER_CANONICAL_NAME_FOR_ALIAS) {
        		System.out.println("this answer contains server string name");
        	}
        	
        	//接下来2字节是级别
        	short cls = buffer.getShort();
        	System.out.println("answer class: " + cls);
        	
        	//接下来4字节是time to live
        	int ttl = buffer.getInt();
        	System.out.println("this information can cache " + ttl + " seconds");
        	
        	//接下来2字节表示数据长度
        	short rdLength = buffer.getShort();
        	System.out.println("content length is " + rdLength);
        	
        	if (type == DNS_ANSWER_CANONICAL_NAME_FOR_ALIAS) {
        		readStringContent(buffer);
        	}
        	
        	if (type == DNS_ANSWER_HOST_ADDRESS) {
        		//显示服务器返回的IP
        		byte[] ip = new byte[4];
        		for (int k = 0; k < 4; k++) {
        			ip[k] = buffer.get();
        		}
        		
        		try {
					InetAddress ipAddr = InetAddress.getByAddress(ip);
					System.out.println("ip address for domain name is: " + ipAddr.getHostAddress());
				} catch (UnknownHostException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
        	}  //if (type == DNS_ANSWER_HOST_ADDRESS)
    	
    	} //for (int i = 0; i < count; i++)
    	
    }
    
    private boolean isNameCompression(byte b) {
    	if ((b & (1<<7)) != 0 && (b & (1<<6)) != 0) {
    		return true;
    	}
    	
    	return false;
    }

private void  readFlags(short flag) {
    	//最高字节为1表示该数据包为回复数据包
    	if ((flag & (1 << 15))!= 0) {
    		System.out.println("this is packet return from server");
    	}
    	
    	//如果第9个比特位为1表示客户端请求递归式查询
    	if ((flag & (1 << 8)) != 0) {
    		System.out.println("client requests recursive query!");
    	}
    	
    	//第8个比特位为1表示服务器接受递归式查询请求
    	if ((flag & (1 << 7)) != 0) {
    		System.out.println("server accept recursive query request!");
    	}
    	
    	//第6个比特位表示服务器是否拥有解析信息
    	if ((flag & (1 << 5)) != 0) {
    		System.out.println("sever own the domain info");
    	} else {
    		System.out.println("server query domain info from other servers");
    	}
    }

这里需要特别注意的一点是,在服务器返回的应答数据中,它会对字符串进行压缩,我们看下图:

屏幕快照 2019-05-09 下午3.50.10.png

我在上头选择字符串pan.baidu.com,但下面只对应两个字节。这是因为回复的数据包为了节省内容长度,如果字符串在数据包的前面出现过,那么它就不会再把相同的数据重复一遍。它会用两个字节表示重复信息,第一个字节的最高两个比特设置成11,表示当前字符串使用压缩表示法,根据上一节描述,当解析数据包字符串时,我们首先读取的是字符个数,如果不采用压缩表示,那么字符个数不允许超过63,因此该字节的头两位绝不可能是11.

如果字节头两位是11,那么我们就确认数据包使用了字符串压缩法。第二个字节告诉我们字符串所在位置相对偏移。例如上图中第二个字节0c表示从DNS数据开始偏移12个字节就是本处要显示的字符串。所以在函数readAnswer的实现中,在读取字符串时,如果发现采用压缩方法,那么它就读取第二个字节获得偏移,然后从数据段起始处偏移相应字节后再进行读取。

更详细的讲解和代码调试演示过程,请点击链接

更多技术信息,包括操作系统,编译器,面试算法,机器学习,人工智能,请关照我的公众号:
这里写图片描述

新书上架,请诸位朋友多多支持:WechatIMG1.jpeg

猜你喜欢

转载自blog.csdn.net/tyler_download/article/details/90039048