用UDP实现向DNS服务器请求IP地址

31 阅读5分钟

IP地址是网络层通信的基础,但是并不方便人类记忆,所以需要DNS域名系统来实现将IP地址和DNS之间的解析,简单来说,域名解析分为两个关键步骤:

  • 本机向本地域名服务器发送一个请求报头(header)
  • 本地域名服务器返回一个响应报文(response),其中包含了IP地址

第一个步骤比较关键,因为构造请求报头的内容需要我们熟悉相关协议的内容和结构,而解析响应报文的代码一般比较固定,在实际项目中可以直接借用,并不需要做深入理解。为了用UDP来发送报文,我们还需要一些网络编程的知识,本文集中了介绍请求报文所应该包含的内容,并且使用C语言实现,如果你缺乏基本的网络编程知识可以看我的另一篇文章:CSAPP网络编程章节实验:Proxylab


DNS请求报文格式

image.png

根据上图,我们可以知道请求报头(Header)中应该包含的内容有:transaction id、flags、questions、anwers、authority、additional,值得注意的是长度为16,所以使用short类型。

struct dns_header {

  unsigned short id;
  unsigned short flags;

  unsigned short question;
  unsigned short answer;
  
  unsigned short authority;
  unsigned short additional;
    
};

image.png

我们的正文(queries)中,name存放了需要查询的名称,比如www.google.com,这个的长度是不固定的,也就是说,如果超出了32,是可以拓展的。

struct dns_question {

  int length;
  unsigned short qtype;
  unsigned short qclass;
  unsigned char *name;
    
};

这些内容中,有一些是比较关键的,比如name不能直接使用www.google.com,而需要构造成特别的形式:3www6google3com,这样比较便于DNS服务器进行递归查询。有一些则不那么重要,比如authority, type之类的,不需要记住。

创建报头函数

int dns_create_header(struct dns_header *header) {
  if (header == NULL) return -1;
  memset(header, 0, sizeof(struct dns_header));

  //随机id即可
  srandom(time(NULL));
  header->id = random();

  //调整为网络字节序
  header->flags = htons(0x0100);
  header->question = htons(1);

  return 0;
}

关键技术阐述:当我们进行网络编程的时候,一定要注意使用htons函数进行网络字节序的转化(比如第11行),即从小端序,转为大端序

  1. 大端序:最高有效字节存储在最低的内存地址(“大端在前”)。

    • 举例:数值 0x12345678 在内存中的存储顺序(从低地址到高地址):12 34 56 78
    • 网络字节序就是采用这种格式
  2. 小端序:最低有效字节存储在最低的内存地址(“小端在前”)。

    • 举例:数值 0x12345678 在内存中的存储顺序(从低地址到高地址):78 56 34 12
    • x86/x64架构的PC采用这种格式

创建DNS查询函数

//header填充
int dns_create_question(struct dns_question *question, const char *hostname) {
  
  if (question == NULL || hostname == NULL) return -1;
  memset(question, 0, sizeof(struct dns_question));

  question->name = (char*)malloc(strlen(hostname) + 2);
  if (question->name == NULL) {
    return -2;
  }

  question->length = strlen(hostname) + 2;
  //网络字节序
  question->qtype = htons(1);
  question->qclass = htons(1);
  

  // name,查询名字格式:3www6google3com
  const char delim[2] = ".";
  char *qname = question->name;
  //复制hostname以方便操作
  char *hostname_dup = strdup(hostname);
  char *token = strtok(hostname_dup, delim);
  
  while (token != NULL) {

    size_t len = strlen(token);

    *qname = len;
    qname ++;

    strncpy(qname, token, len+1);//这里已经包括了最后的'\0'
    qname += len;

    token = strtok(NULL, delim);
    
  }
  
  free(hostname_dup);
  return 0;
} 

关键技术阐述:以上代码中的22到37行实现了域名格式的转换,实现的关键在于使用strtok函数,这个函数的功能是从前往后查找delim所在位置,一旦遇到,就将delim前的内容返回,得到token。定义delim为“.”的时候,就会将www返回到token当中。同样的道理,使用while循环不断向后寻找google和com这两个token,并且将长度返回到指定的字符串qname的位置上,最后我们就获得了需要的查询名格式。


提交DNS请求

将header和question整合进request里之后,就可以提交DNS请求了。

int dns_client_commit(const char *domain) {
  int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

  if (sockfd < 0) {
    return -1;
  }

  struct sockaddr_in servaddr = {0};
  servaddr.sin_family = AF_INET;
  servaddr.sin_port = htons(DNS_SERVER_PORT);
  servaddr.sin_addr.s_addr = inet_addr(DNS_SERVER_IP);

  int ret = connect(sockfd, (struct sockaddr*) &servaddr, sizeof(servaddr));
  printf("connect: %d\n", ret);
  
  struct dns_header header = {0};
  dns_create_header(&header);

  struct dns_question question = {0};
  dns_create_question(&question, domain);
  //将header和question整合进request里
  char request[1024] = {0};
  int length = dns_build_request(&header, &question, request, 1024);

  //提交DNS请求
  int slen = sendto(sockfd, request, length, 0, (struct sockaddr*) &servaddr, sizeof(struct sockaddr_in));

  //recvfrom函数,接收DNS服务器的响应
  char response[1024] = {0};
  struct sockaddr_in addr;
  size_t addr_len = sizeof(struct sockaddr_in);
  
  int n = recvfrom(sockfd, response, sizeof(response), 0, (struct sockaddr*) &addr, (socklen_t*) &addr_len);

  printf("recvfrom : %d, %s\n", n, response);

  return n;
}

关键技术阐述:这些代码是比较固定的创建一个套接字连接的方式,(2~6行)先使用socket函数返指定使用的IPv4(参数AF_INET)、UDP(SOCK_DGARM)的协议,成功后返回socket描述符。(8~17行)然后指定servaddr结构体当中的内容:包括端口,DNS服务器的IP以及所使用的协议族。值得注意的是,13~14行使用了connect,其实在UDP编程中,并不必须要设置好connect,因为不需要像TCP一样三次握手,但是为了方便调试,还是先建立连接。最后,(16行以后)调用我们之前实现的header构造函数和整合函数后发送请求到DNS服务器,用recvfrom接收回应报文即可。