功能背景:通过服务器的DHCP、DNS服务器直接管理所有客户端IP信息。
在Windows Server 已有的机制下,DHCP是会默认 向DNS 发送 主机+域名后缀的域名信息的,但是可能存在记录延迟、丢失等问题,所以希望通过“DNS动态更新”协议,客户端直接向DNS服务器发送域名信息,尽可能保证数据完整。
下面会从“服务器开启功能的配置”和"客户端协议包内容解析"两个方面来总结学习这个的过程中我是如何实现的。
目录
1. 开启对应的角色功能: “DHCP 服务器”、”DNS服务器“、“Active Directory 域服务(AD DS)”
4. 通过Windows 10 测试 “DNS动态更新域名” 功能是否可用
一. 服务器开启功能的配置
以现场环境以Windows Server 2019为例,其他版本应该也大同小异,目的是打开服务器的”DNS服务器动态更新域名的功能“。
官方说明文档: 如何在 Windows Server 中配置 DNS 动态更新 - Windows Server | Microsoft Docs
1. 开启对应的角色功能: “DHCP 服务器”、”DNS服务器“、“Active Directory 域服务(AD DS)”
2. DHCP服务器向DNS服务器自动更新的方式。
如果不需要"DNS动态更新功能",由DHCP服务器向DNS服务器更新域名信息,则开启 “DHCP 服务器”、”DNS服务器“即可,在DHCP、DNS中配置对应的配置即可。
DNS新建一个区域为: 父域后缀为:jayce1220.com 动态更新选为:非安全
DNCP也需要新建一个域进行配置:DNS服务器地址需要填写服务器IP地址,DNS域名即为刚刚设置的域名。
这样配置好了之后,有客户端接入进来的时候,如果是通过这个DHCP服务器获取的IP地址就会生成一条记录,并且会在一段时间后同步到DNS服务器上(可能会延迟)。
3. 开启AD DS,客户端向DNS服务器动态更新域名
因为之前角色已经添加了“Active Directory 域服务(AD DS)”功能,如图所示,点击图标:
然后"添加新林",按提示下一步至安装即可,安装完成之后会提示重启计算机。
重启之后在"属性"中可以看见,计算机已经在创建的域名下了,表示添加域成功。
打开"DNS管理器",可以把刚刚创建的DNS服务器的域添加到 "Active Directory集成区域"中了。
最后保存确认即可,"DNS动态更新域名"服务端就搭建完毕了。
4. 通过Windows 10 测试 “DNS动态更新域名” 功能是否可用
这个会直接绕开DHCP服务器,设置windows的网络适配器网络参数配置 - IP获取方式采用静态设置的方式 - 设置DNS服务器地址(指向刚刚的DNS服务器地址) - 高级
配置里面的DNS栏目,然后确定保存所有配置。
再重启一下网络,使客户端重新加载网络,再回到服务端的DNS服务器,即可看见添加了一条DNS域名解析记录(抓取网络数据包可以看到这条信息,下面再搭配RFC协议分析网络数据包内容),以此可以验证"DNS动态更新域名"功能已打开。
二. 客户端协议包内容解析
Windown doc可知 动态更新协议是基于RFC 2136 协议规范通信的,而 RFC 2136 报文内容 又是由 RFC 1035协议规范统一说明的,这里主要是通过RFC 1035/2136 协议,结合Windows客户端向DNS服务器抓包的报文信息来进行分析。这里需要注意的是,DNS的通信协议 udp和tcp的端口都是: 53
1. 解析Windows客户端向DNS服务器发送的报文信息
在Windows客户端上配置完DNS等网络信息后,重启网络状态,可以在DNS的服务器上 获取到两条来自Windows客户端的发包信息。一条是查询信息query,一条是更新信息Dynamic update。
根据更新报文,搜索出来的大部分中文资料都是DNS查询报文的分析资料,这里以这两位大佬的博客为引:
1.1 查询报文
先分析查询的报文结构:
完全符合协议报文格式:
其中我遇到较为难理解的是 "标志(Flags)" 和 "查询问题区域(Queries)"
"标志(Flags)" 是由二进制 16Bit 转换成16进制表示。 说明及用Java代码封装说明如下:
/**
* 头部flags标识结构 16bit位=2字节表示
* 0 : qr 查询标识=1bit
* 1-4 : opcode 0=Query 1=IQuery(Inverse Query) 2=Status 3=Unsassined 4=Notify 5=Update
* 5 : AC (authoritative answer) 授权回答=1bit
* 6 : TC (Truncated):表示是否被截断。值为 1 时,表示响应已超过 512 字节并已被截断,只返回前 512 个字节。=1bit
* 7 : RD (Recursion Desired):期望递归 =1bit
* 8 : RA (Recursion Available):可用递归=1bit
* 9-11 : Z 保留字段,在所有的请求和应答报文中,它的值必须为 0。=3bit
* 12-15 rcode =4bit
* 总共 16bit --结构如下:
* 0 | 1-4 | 5 | 6 | 7 | 8 | 9-11 | 12-15 |
*
* @param opcode BUILD_TYPE_**** 定义的类型
* @return 2个字节的flage
*/
private static byte[] getDnsFlags(int opcode) {
//缓存 16 bit的数据
byte[] bits = new byte[16];
switch (opcode) {
case BUILD_OPCODE_QUERY:
bits[7] = 1;
break;
case BUILD_OPCODE_UPDATA:
bits[4] = 5;//opcode=5=Update
break;
}
//最后转换成字节数据
int temp = 0;
for (int i = 0; i < bits.length; i++) {
temp |= bits[i] << (bits.length - 1 - i);
}
return DataUtils.toBigEndian((short) temp);
}
"查询问题区域(Queries)" 由 域名(ascii码16进制)+Type+Class 组成。type 和 Class 在RFC 1035协议中定义,链接2 的博客也有列出了这部分内容。
域名规则如上图所示以“DESKTOP-4686QLT.jayce777.com”为例,因为每一域名级别最长仅支持63个字符,域名总长度则不能超过253个字符,所以按域名级别划分 转换ascii后,会在前面每一域名级别前面添加一个字节表示域名级别长度(红色、橙色、黄色),末尾用00表示域名地址结束。例如0x0f=15,4445534b544f502d34363836514c54=DESKTOP-4686QLT...
使用Java代码封装如下:
/**
* 获取域名地址按照规则转换的字节信息:
* 1.字符串以"."分割,获得一个数组,数组的每个字符串(域名等级名)元素转换成**编码格式字节。
* 2.每个元素的的字节数组在前面加上一个表示长度的字节(顶级域名长度理论长度为63个字节)。
* 3.转换之后将各个元素组合起来,末尾增加一个"0"字节的表示介绍。
* 以二级域名地址("DESKTOP-4686QLT.jayce777.com")举例,装换为16进制,结构如下:
* 0f4445534b544f502d34363836514c54086a6179636537373703636f6d00 分割一下--->
* 0f 4445534b544f502d34363836514c54 08 6a61796365373737 03 636f6d 00
* 0f 08 03表示长度,末尾00表示结束
*
* @param address 域名地址
* @return
*/
private static byte[] dnsAddress2DnsMessage(String address) {
byte[] bytes = new byte[0];
String[] split = address.split("\\.");
for (String part : split) {
byte[] partBytes = part.getBytes();
byte[] bytesCache = concatBytes(new byte[]{(byte) partBytes.length}, partBytes);
bytes = concatBytes(bytes, bytesCache);
}
bytes = concatBytes(bytes, new byte[]{0});//结尾需要00标识
return bytes;
}
至此通过抓包获取的报文,结合协议文档及链接的博客文献基本可以封装出查询的报文数据,没有特殊的查询条件,关键点也仅在于 "标志(Flags)"中的qr 和 opcode参数,以及 "查询问题区域(Queries)"域名报文生成规则、Type和Class参数。规则长度为 id(2)+flags(2)+4个计数器(8)+查询问题(域名规则生成(n)+type(2)+class(2)=n+16),使用Java封装了一个类,详见文末。
顺带一提,返回的的参数是通过"标志(Flags)" 中的 Reply code,
查询到的:
查询不到的:
本次需求不需要DNS的返回结果,所以这里不再赘述。
1.2 DNS动态更新报文
通过查询报文的分析,大致了解个DNS协议各个报文空间的含义。再来分析DNS更新的报文内容。
这里发现Opcode的值 变成了 5 = Update,记录计数器也发生了变化,但是还是用总共用8个字节表示4个记录计数器,12字节构成DNS报文首部。通过RFC 2136 协议文档也可以看见报文结构:
针对Zone、Prerequisites、Upadate三个区域含义,在RFC 2136摘要中是这么描述的:
结合后续的开发过程,这里我是这么理解的,"Zone"和“Prerequisites”是必须的,“Zone”指向这次更新的是哪个DNS服务器正向区域,“Prerequisites”直译即为"必要条件",这个在“update”中的压缩报文中也有重要意义。协议中也涉及类型是SOA类型,所以开发时报文的type参数和class参数,直接沿用了抓包报文里面的参数,没有过多的再去研究RFC 2136。需要注意的是这两个数据信息字节长度规则:
Zone域名地方的方式沿用"查询问题区域(Queries)" 的域名规范,长度为:"域名(n)+type(2)+class(2)=n+4"
/**
* Zone包构建
*
* @param dnsSuffix dns后缀地址
* @param type DNS资源记录
* @param classes dns后缀地址
* @return
*/
private static byte[] getZone(String dnsSuffix, short type, short classes) {
byte[] dnsBytes = dnsAddress2DnsMessage(dnsSuffix);
byte[] typeBytes = DataUtils.toBigEndian(type);
byte[] classesBytes = DataUtils.toBigEndian(classes);
return concatBytes(dnsBytes, typeBytes, classesBytes);
}
Prerequisites也基本和zone一样,但是添加了ttl和data length的信息。长度为:"域名(n)+type(2)+class(2)+ttl(4)+DL(2)+Data(DL)=n+DL+10"
/**
* @param dnsAddress dns正向解析地址
* @param type Dns Type
* @param classes Dns classes
* @param ttl TTL时间,单位秒
* @param dataLength 数据长度,一般为0
* @return
*/
private static byte[] getPrerequisites(String dnsAddress, short type, short classes, int ttl, short dataLength) {
byte[] dnsBytes = dnsAddress2DnsMessage(dnsAddress);
byte[] typeBytes = DataUtils.toBigEndian(type);
byte[] classesBytes = DataUtils.toBigEndian(classes);
byte[] timeToLiveBytes = DataUtils.toBigEndian(ttl);
byte[] dataLengthAndDataBytes = DataUtils.toBigEndian(dataLength);
return concatBytes(dnsBytes, typeBytes, classesBytes, timeToLiveBytes, dataLengthAndDataBytes);
}
Updates 里面3份数据(我这显示4是因为电脑配置了两个IP),测试是发现有最后一份带地址的数据的即可实现,因为没有完全理解RFC 2136这里还是和抓包报文保持一致,发送三份更新信息。(Updates 从文档上看是可以实现删除功能的,但是我这边暂时还没有做出来 =。=!)
Update的数据格式结构基本也和Prerequisites一致,包含"域名+type+class+ttl+data length+data" 组成,但是这里的域名采用的是压缩域名的方式。压缩规则:从TransactionID,index=0开始计算至域名(Prerequisites区域)第一个字符的的长度,为偏移量,用以压缩报文大小。
以上图为例,4be9为TransactionID 4b为偏移计算位置start=0,0f为偏移计算位置end,域名位置起始,差值为1f=31,c0为预留字段。报文及Java代码封装如下:
/**
* @param offset 从TransactionID,index=0开始计算至域名第一个字符的的长度,为偏移量,用以压缩报文大小
* @param type Dns Type
* @param classes Dns classes
* @param ttl TTL时间,单位秒
* @param ipAddress IP地址
* @return
*/
private static byte[] getUpdata(byte offset, short type, short classes, int ttl, String ipAddress) {
byte[] dnsBytes = {(byte) 0xc0, offset};//C0是预留字段
byte[] typeBytes = DataUtils.toBigEndian(type);
byte[] classesBytes = DataUtils.toBigEndian(classes);
byte[] timeToLiveBytes = DataUtils.toBigEndian(ttl);
byte[] dataLengthAndDataBytes = null;
if (ipAddress != null && !"".equals(ipAddress) && ipAddress.contains(".")) {
String[] split = ipAddress.split("\\.");
int length = split.length;
dataLengthAndDataBytes = new byte[2 + length];
dataLengthAndDataBytes[0] = (byte) (length >>> 8 & 0xff);
dataLengthAndDataBytes[1] = (byte) (length & 0xff);
for (int i = 0; i < length; i++) {
dataLengthAndDataBytes[2 + i] = (byte) (Integer.parseInt(split[i]) & 0xff);
}
} else {
dataLengthAndDataBytes = new byte[]{00, 00};
}
return concatBytes(dnsBytes, typeBytes, classesBytes, timeToLiveBytes, dataLengthAndDataBytes);
}
至此DNS动态报文:Header + Zone + Prerequisite + Update + Additional Data(没有为0) 均已构建出来,再使用生产报文发送到DNS服务器的53端口即可。
相关文档及Java代码及源码见附件,可以jdk环境下运行直接测试。