DNS Relay Server 开发记录

一、课设要求分析

BUPT 的课设给出了如下要求:

  1. 本地域名解析:读取"域名-IP地址"对照表文件,根据查询的域名返回对应IP ;
  2. 不良网站拦截:当域名对应的IP为0.0.0.0时,向客户端返回"域名不存在"的报错;
  3. 中继功能:当本地无法解析域名时,向因特网DNS服务器发出查询,并将结果返回给客户端;
  4. 并发处理:支持多个客户端同时查询,需要进行消息ID的转换;

需要实现的 DNS Relay Server 更像是客户端和 DNS Resolver 之间的中间件,毕竟如果没有添加对本地域名的解析和对不良网站的拦截的要求,那它剩下的功能就只是把客户端的请求直接转给 ISP DNS Resolver ,本身也不查也不解析。

某些方面来说,它承担了部分 Local Name Server ,即本地域名服务器的缓存功能,但递归查询/迭代查询事实上是直接交给上游 DNS Server 做了。或者也可以这么说, 它和上游服务器之间是一个递归查询关系,上游服务器会把查询完的结果返回给它。

二、项目结构的梳理

系统架构大致是这样的:

1
2
3
4
5
6
7
8
9
main.c

├── threadpool/ → 线程池管理(并发处理客户端请求)
├── dns/ → DNS 报文的构建、解析与中继
├── cache/ → LRU 缓存(key = domain + QTYPE)
├── hashtable/ → 泛型哈希表实现,支持结构体键
├── array/ → 动态数组,存储多个 DNS RR
├── local/ → 本地记录管理
└── util/ → 辅助函数,如日志、时间戳、错误处理等

三、主程序及工作流程梳理

3.1 工作流程

  1. 启动流程:
    • 加载配置文件中的域名-IP对照表
    • 初始化线程池、缓存系统和本地记录表
    • 创建UDP套接字并绑定到指定端口
    • 开始监听DNS请求
  2. 请求处理流程:
    • 接收客户端DNS请求
    • 解析DNS请求报文,提取查询的域名
    • 按照以下顺序查找域名:
      1. 本地域名-IP对照表
      2. DNS缓存
      3. 向外部DNS服务器中继查询
    • 构造响应报文并发送给客户端

3.2 主函数

main 函数内部基本只有初始化数据结构和网络资源,然后进入主循环 handle_requests ,处理请求,程序退出时释放资源。

3.3 初始化函数

没什么好说的。包括初始化互斥锁和本地 DNS 表。

3.4 套接字初始化

创建 UDP 套接字,绑定套接字到指定端口(53)。

3.5 主请求处理循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void handle_requests() {
uint8_t buffer[BUFFER_SIZE];
struct TaskArgs *args;
struct sockaddr_in client_addr;
socklen_t client_addr_len;

while (1) {
memset(buffer, 0, BUFFER_SIZE * sizeof(uint8_t));
client_addr_len = sizeof(client_addr);

ssize_t recv_len = recvfrom(relay_sock, buffer, BUFFER_SIZE, 0, (struct sockaddr *)&client_addr, &client_addr_len);

if (recv_len < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
continue;
}
perror("[Error] recvfrom relay_sock failed");
continue;
}

args = (struct TaskArgs *)malloc(sizeof(struct TaskArgs));
args->buffer = (uint8_t *)malloc(BUFFER_SIZE);
memcpy(args->buffer, buffer, BUFFER_SIZE);
args->sockaddr = client_addr;
args-> = recv_len;

thpool_add_job(thpool, serve, args);
}
}
  1. 循环接收客户端发出的 DNS 请求;
  2. 为每个请求创建任务参数结构;
  3. 将请求添加到线程池的任务队列;
  4. 如此循环;

3.6 请求处理函数

Serve 实现了完整的对 DNS 请求对解析和处理。

首先,该函数先对任务参数进行解析,然后创建用于转发的套接字(这里还设置了套接字超时的处理),再是一些调试信息,最后进入具体的处理阶段。

  1. 查本地缓存:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/* 先判断请求类型 */
bool canCache = true;
/* 非缓存类型直接进行中继转发 */
if (request.query.type != DNS_TYPE_A) {
canCache = false;
goto RELAY_REQUEST;
} else /* 是缓存类型再根据缓存判断 */ {
/* 先从local_dns_table中查找 */
pthread_mutex_lock(&mutex_ldt);
ht_node_t *ht_node = ht_lookup(local_dns_table, request.query.name);
pthread_mutex_unlock(&mutex_ldt);

if (ht_node != NULL) {
local_record_t *record = (local_record_t *)ht_node->value;
assert(record != NULL);
/* 给定header,header不能先设置,还要判断IP是否被封禁 */
parse_dns_header(&header, buffer);

char *ip = record->ip;

/* 判断IP是否被封禁 */
if (!strcmp(ip, BLACK_IP)) {
init_flags(&flags, QR_RESPONSE, header.flags.OPcode, 0, 0, header.flags.TC, 1, 0, 5);
init_header(&header, header.id, flags, header.QDCOUNT, 0, 0, 0);

put_header(&header, buffer);
/* 因为被封禁,所以根本不提供answer部分 */
back_len = relay_recv_len;
} else {
init_flags(&flags, QR_RESPONSE, header.flags.OPcode, 1, 0, header.flags.TC, 1, 0, 0);
init_header(&header, header.id, flags, header.QDCOUNT, 1, 0, 0);

put_header(&header, buffer);
int compression_count = 0;
DnsNameOffsetEntry compression_table[MAX_ENTRY_COUNT];
DnsMessageAnswer *answer = RR_init();
answer->type = 1;
answer->class = 1;
answer->ttl = DEFAULT_TTL;
answer->rdlength = 4;
inet_pton(AF_INET, ip, &answer->rdata.a_record.ipv4_address);
strcpy(answer->name, record->domain);
/* 在local_dns_table中的记录不需要Authority & Additional */
/* put answer */
if (debug_level > 0) {
printf("[DEBUG] recv_len: %ld\n", relay_recv_len);
}
back_len = relay_recv_len + put_answer(answer, buffer, relay_recv_len, compression_table, &compression_count);
RR_delete(answer);
}
}

本地记录查询部分:

  1. 检查请求类型,只缓存A记录(IPv4地址)
  2. 在本地DNS表中查找域名
  3. 如果找到且IP为0.0.0.0,构造拒绝响应(RCODE=5)
  4. 如果找到普通IP,构造正常响应,包含A记录
  5. 使用互斥锁保护本地DNS表访问

然后在本地 DNS 缓存中查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pthread_mutex_lock(&mutex_dc);
cache_node_t *cache_node = cache_lookup(dns_cache, request.query.name);
pthread_mutex_unlock(&mutex_dc);
if (cache_node != NULL) {
array_t *RRs = cache_node->RRs;
/* 还需要判断是否超出UDP报文大小,这个放在最后发送的位置进行判断 */
init_flags(&flags, QR_RESPONSE, request.header.flags.OPcode, 1, 0, request.header.flags.TC, 1, 0, 0);
init_header(&header, request.header.id, flags, request.header.QDCOUNT, RRs->length, 0, 0);
put_header(&header, buffer);
int offset = 0;
int compression_count = 0;
DnsNameOffsetEntry compression_table[MAX_ENTRY_COUNT];
for (int i = 0; i < RRs->length; i++) {
DnsMessageAnswer *ans = &array_index(RRs, i, DnsMessageAnswer);
offset += put_answer(ans, buffer, relay_recv_len + offset, compression_table, &compression_count);
}
back_len = relay_recv_len + offset;
}
  1. 在DNS缓存中查找域名
  2. 如果找到,构造响应报文,包含所有缓存的资源记录
  3. 使用互斥锁保护DNS缓存访问

最后如果都没找到,则转发请求:

  1. 生成随机ID,替换原始ID(ID转换)
  2. 将请求转发到外部DNS服务器
  3. 实现重传机制,最多重试3次
  4. 接收外部DNS服务器的响应
  5. 将响应中的ID替换回原始ID(ID回转)

处理响应部分:

  1. 如果未能从外部DNS服务器获取响应,构造服务器错误响应
  2. 如果成功获取响应,解析响应报文
  3. 检查响应是否包含A记录,决定是否缓存
  4. 如果可以缓存,将响应添加到DNS缓存(只缓存 IPv4 地址)
  5. 如果不缓存,释放响应中的资源

最后:

  1. 将响应发送回客户端
  2. 释放请求域名内存
  3. 关闭转发套接字
  4. 释放任务参数和缓冲区
  5. 返回NULL,完成任务

3.7 程序运行流程

3.7.1 启动流程

  1. 解析命令行参数,获取配置
  2. 初始化数据结构(哈希表、缓存、线程池)
  3. 初始化网络资源(UDP套接字)
  4. 读取本地记录文件,填充本地DNS表

3.7.2 请求处理流程

  1. 接收客户端DNS请求
  2. 解析DNS请求报文
  3. 查询处理顺序:
    • 先查询本地DNS表
    • 如果本地表未命中,查询DNS缓存
    • 如果缓存未命中,转发到外部DNS服务器
  4. 构造响应报文:
    • 本地记录命中:直接构造响应
    • 缓存命中:使用缓存记录构造响应
    • 外部查询:转发响应,并可能缓存结果
  5. 发送响应给客户端

3.7.3 不良网站拦截流程

  1. 在本地DNS表中查找域名
  2. 如果找到且IP为0.0.0.0,构造拒绝响应
  3. 设置响应码RCODE=5,表示拒绝请求
  4. 不包含任何资源记录

3.7.4 缓存管理流程

  1. 从外部DNS服务器获取响应后,检查是否可缓存
  2. 只缓存包含A记录的响应
  3. 创建缓存节点,保存域名和资源记录
  4. 插入LRU缓存,可能触发过期项清理或LRU淘汰

3.7.5 ID转换流程

  1. 生成随机ID,替换原始请求ID
  2. 将请求转发到外部DNS服务器
  3. 接收响应后,将ID替换回原始ID
  4. 这种机制解决了多客户端并发请求时的ID冲突问题

四、数据结构的梳理

涉及的几种数据结构包括:

  1. 本地记录表
  2. DNS Cache
  3. DNS 报文结构

4.1 哈希表

首先,用哈希表来干嘛?

  1. 本地域名记录表:
    • 键:域名字符串
    • 值:IP地址字符串或本地记录结构体
    • 用途:快速查找本地配置的域名-IP映射
  2. DNS缓存的底层存储:
    • 键:域名字符串
    • 值:缓存节点结构体,包含DNS资源记录
    • 用途:提供O(1)时间复杂度的缓存查找

4.1.1 哈希表节点

include/hashtable.h

1
2
3
4
5
typedef struct HTNode {
struct HTNode *prev, *next;
void *key;
void *value;
} ht_node_t;
  • 双向链表用于解决哈希表冲突,同时也方便节点删除;
  • void* :通用指针,存储任意类型的键值对,和 Java 的泛型差不多吧;

4.1.2 哈希表

1
2
3
4
5
6
7
8
9
typedef struct HashTable {
ht_node_t **nodes; /* 表 */
ht_hash_func hash; /* 哈希函数 */
ht_compare_func comp; /* 比较函数 */
ht_clear_func clear;
uint32_t capacity; /* 容量 */
uint32_t size; /* 实际数目 */
uint32_t key_size;
} hash_table_t;
  • 桶数组:nodes是一个指针数组,每个元素指向一个哈希桶的头节点;
  • 函数指针:
    • hash:计算键的哈希值
    • comp:比较两个键是否相等
    • clear:清理节点资源的函数

4.1.3 函数指针类型

1
2
3
typedef uint32_t (*ht_hash_func)(void *, uint32_t, uint32_t);
typedef int (*ht_compare_func)(void *, void *, uint32_t);
typedef void (*ht_clear_func)(ht_node_t *);
  • 哈希函数:接收键、键大小和哈希表容量,返回哈希值;
  • 比较函数:比较两个键是否相等,返回0表示相等;
  • 清理函数:负责释放节点相关的资源;

4.1.4 哈希函数

1
2
3
4
5
6
static uint32_t fnv_hash_1a_32(void *key, uint32_t len) {
uint8_t *p = key;
uint32_t h = 0x811c9dc5;
for (uint32_t i = 0; i < len; i++) h = (h ^ p[i]) * 0x01000193;
return h;
}

4.1.5 通用哈希函数包装器

1
2
3
4
5
6
7
static uint32_t ht_hash(void *key, uint32_t key_size, uint32_t capacity) {
if (key_size == STRING) {
return fnv_hash_1a_32(key, strlen((const char *)key)) % capacity;
} else {
return fnv_hash_1a_32(key, key_size) % capacity;
}
}
  • 对于字符串类型,使用其实际长度计算哈希值;
  • 对于固定大小类型,使用提供的 key_size ;
  • 最后对哈希表容量取模,确保结果在有效范围内;

4.1.6 键比较函数

1
2
3
4
5
6
7
8
int ht_str_comp(void *s1, void *s2, uint32_t key_size) {
assert(s1 && s2);
return strcmp((const char *)s1, (const char *)s2);
}

static int ht_default_comp(void *s1, void *s2, uint32_t key_size) {
return memcmp(s1, s2, key_size);
}

提供了两种键比较方式:

  • 字符串比较:使用标准库的 strcmp 函数;
  • 内存比较:使用 memcmp 函数比较固定大小的内存块;

4.1.7 哈希表操作函数

主要包括初始化,查找、插入、删除。

4.2 动态数组部分

首先,动态数组用来干嘛?

主要用于存储 DNS 响应中长度不固定的资源记录 Resource Records ;用在解析 DNS 响应时,存储解析后的资源记录结果;在构造 DNS 响应时遍历。

缓存节点使用动态数组存储与域名关联的所有资源记录。

4.2.1 定义

1
2
3
4
5
6
7
// array.h
typedef struct {
void *data; // 存储数据的缓冲区
size_t element_size; // 每个元素的大小(字节)
size_t length; // 当前元素数量
size_t capacity; // 当前分配的容量
} array_t;

同样,通过 element_size ,使得该数组能存储任意类型

4.2.2 操作函数

初始化、插入、删除、扩容、访问元素等;

关键:类型安全的访问宏

1
#define array_index(array, index, type) (*(type*)(array_get(array, index)))

首先将 void* 转换为特定类型的指针;然后对其进行解引用,返回元素值而非指针。实现像普通数组一样访问元素。

4.2.3 资源释放相关

1
2
3
4
5
6
void array_free(array_t *array) {
assert(array != NULL);

free(array->data);
free(array);
}

动态数组只负责释放数据缓冲区和结构体本身。其余由调用者清理。

4.3 LRU 缓存

LRU,即 Least Recently Used,最近最少使用。基于局部性原理,最近访问过的数据在不久的将来被再次访问的可能性更高。所以:

  1. 当缓存满时,优先淘汰最长时间未被访问的项;
  2. 每次访问缓存项时,将其移到"最近使用"位置;
  3. 新项添加到"最近使用"位置;

4.3.1 缓存定义

1
2
3
4
5
typedef struct CacheNode {
char *domain; /* 域名,作为缓存的键 */
array_t *RRs; /* 资源记录数组,作为缓存的值 */
time_t update_time; /* 更新时间,用于TTL过期判断 */
} cache_node_t;

4.3.2 链表节点

1
2
3
4
5
typedef struct ListNode {
struct ListNode *prev; /* 前驱节点 */
struct ListNode *next; /* 后继节点 */
cache_node_t *value; /* 指向缓存节点 */
} list_node_t;

同样适用双向链表,便于节点移动。

4.3.3 LRU 缓存链表

1
2
3
4
5
6
typedef struct Cache {
list_node_t *head; /* 链表头(最近使用的项) */
list_node_t *tail; /* 链表尾(最久未使用的项) */
hash_table_t *hashtable; /* 哈希表,用于快速查找 */
uint32_t size; /* 当前缓存项数量 */
} lru_cache_t;

使用 hashtable,同样用于加速查找效率;头尾指针用于跟踪使用的顺序;

4.3.4 操作函数

缓存初始化,查找(注意找到后还需要将节点移动到列表头部,表示最近使用位置),插入节点等。

注意插入节点时,如果满了,会先调用 cache_cleanup_expired 删除 TTL 过期的节点,如果还是满,那在调用 cache_delete 删除最久未使用的节点(即尾节点)。

4.3.4.1 删除缓存项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void cache_delete(lru_cache_t *list, list_node_t *node) {
assert(list != NULL && node != NULL);

if (node == list->head) list->head = node->next;
if (node == list->tail) list->tail = node->prev;

if (node->prev != NULL) node->prev->next = node->next;
if (node->next != NULL) node->next->prev = node->prev;

/* 释放 hashtable 中对应项 */
cache_node_t *entry = node->value;
ht_delete(list->hashtable, entry->domain);

/* 释放 list_node_t */
free(node);
--list->size;
}

该函数统一封装了删除操作,用于缓存项生命周期的管理,由于缓存结构同时被哈希表和 LRU 缓存链表管理,为避免双重释放的问题,明确了由缓存链表负责生命周期的管理,哈希表只负责查找,在该函数中首先释放对应哈希表和链表中的项,再删除节点。

4.3.4.2 清理过期节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void cache_cleanup_expired(lru_cache_t *list) {
assert(list != NULL);

/* 从链表尾部先开始清理 */
list_node_t *cur_node = list->tail;
list_node_t *prev = NULL;

while (cur_node != NULL) {
prev = cur_node->prev;
cache_node_t *entry = cur_node->value;

if (cache_is_expired(entry)) {
cache_delete(list, cur_node);
}

cur_node = prev;
}
}

从链表尾开始,逐步判断每个节点是否过期。

4.3.4.3 过期判断逻辑

1
2
3
4
5
6
bool cache_is_expired(cache_node_t *node) {
assert(node != NULL);
time_t now = time(NULL);
DnsResourceRecord *RR = &array_index(node->RRs, 0, DnsResourceRecord);
return (now - node->update_time) > RR->ttl;
}

获取当前时间,减去缓存项中的更新时间 update_time ,就是节点已缓存的时间,与对应资源记录中的 TTL 进行比较即可。

五、问题梳理

5.1 DNS 部分

5.1.1 为什么需要进行 ID 转换?

多个客户端可能同时发出 DNS 查询,它们的查询 ID 可能相同(这里还涉及并发处理请求)。当外部 DNS 服务器返回响应时,中继服务器需要知道这个响应对应哪个客户端的请求,如果不进行 ID 转换,那么当收到外部 DNS 服务器的响应时,无法确定应该转发给哪个客户端。

所以在我们的 DNS Relay 上,需要维护一个原始 ID 和新 ID(即 DNS 将请求转发给外部 DNS 服务器时,所构造的查询包)的映射关系。

5.2 哈希表部分

5.2.1 哈希表设计如何支持DNS中继服务器的特定需求?

我们的哈希表设计考虑了以下DNS特定需求:

  1. 支持域名作为键(字符串类型);
  2. 通过函数指针支持自定义的比较函数,以实现大小写不敏感的域名比较;
  3. 使用FNV哈希算法,该算法对字符串有良好的散列效果,适合域名处理(具体见下面的叙述);
  4. 支持清理函数,便于管理DNS资源记录等复杂结构的内存;

5.2.2 为什么选择链地址法处理哈希冲突?

  1. 实现简单直观,便于调试和维护;
  2. 在负载因子合理的情况下性能表现良好;
  3. 支持高效的删除操作,这对于缓存淘汰机制(LRU)很重要;
  4. 内存分配更灵活,不需要预先分配大块连续内存;

5.2.3 FNV-1a 哈希算法好在哪?

  1. 计算速度快,能快速处理实时数据:

每处理一个字节仅需要两个操作:1 次异或 + 1 次乘法;

  1. 冲突率低,散列分布好
  • 先通过异或打乱输入模式,这个操作立刻引入当前字节对哈希值的影响,无论字节顺序如何变化,都能快速扰动哈希状态,使得即使输入之间差别极小(比如只改了一个字符),也能得到完全不同的哈希结果;
  • 接下来乘以质数增强级联扰动,使用了一个精心挑选的大质数(如 16777619),它会进一步放大异或后的微小差距,形成雪崩效应,即:“输入变化一点,输出变化很多”;

它的高度分散使它适用于 hash 一些非常相近的字符串,比如 URL ,hostname ,文件名,text ,IP 地址等。

5.2.4 哈希表的扩容策略是什么?

首先,需要明确负载因子 = 元素数量 / 桶数量;具体扩容策略如下:

  1. 当负载因子超过 0.75 时触发扩容;
  2. 扩容时将容量增加到原来的 2 倍(一般默认行为);
  3. 重新计算所有的键值并分配到对应的桶;
  4. 扩容的复杂度为 O(n),但分摊到每次插入到操作就是 O(1) ;

注:初始容量为 1024 ,足以容纳本地域名记录,同时分布较好,内存开销适中。

5.2.5 哈希表在最坏情况下的性能如何?

最坏情况:所有键都 hash 到一个桶,查找、删除、插入的复杂度都会退化为 O(n);

5.2.6 为什么用双向链表?

  1. 支持 O(1) 复杂度的节点删除,无需遍历查找前驱节点;
  2. 适用于 LRU 缓存中节点的频繁移动和删除;

5.2.7 清理函数的设计如何确保资源正确释放?

  1. 要求调用者提供自定义的清理函数;
  2. 清理函数负责释放键值对相关的内存和资源;
  3. 哈希表本身只负责节点结构的内存管理;

LRU 缓存实现中,由于缓存节点同时被链表和哈希表引用,所以,先由 ht_delete 调用哈希表的清理函数,而缓存的清理函数则负责释放缓存节点及其包含的资源记录;

六、DNS 协议的解析与构造

这部分应该没什么好讲的,主要是一些自定义的结构体,C 语言结构体的存储其实和报文格式长得还蛮像的。

6.1 DNS 的资源记录 RR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
typedef struct {
uint32_t ipv4_address;
} A_RData;

typedef struct {
struct in6_addr ipv6_address;
} AAAA_RData;

typedef struct {
char *cname;
} CNAME_RData;

typedef union {
A_RData a_record;
AAAA_RData aaaa_record;
CNAME_RData cname_record;
} RData;

typedef struct DnsResourceRecord {
char *name;
uint16_t type;
uint16_t class;
uint32_t ttl;
uint16_t rdlength;
RData rdata;
} DnsResourceRecord;

6.2 DNS Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct DnsMessageHeaderFlags {
uint8_t QR; /* 0 */
uint8_t OPcode; /* 1-4 */
uint8_t AA; /* 5 */
uint8_t TC; /* 6 */
uint8_t RD; /* 7 */
uint8_t RA; /* 8 */
uint8_t Z; /* 9-11 */
uint8_t RCODE; /* 12-15 */
} DnsMessageHeaderFlags;

typedef struct DnsMessageHeader {
uint16_t id;
struct DnsMessageHeaderFlags flags;
uint16_t QDCOUNT;
uint16_t ANCOUNT;
uint16_t NSCOUNT;
uint16_t ARCOUNT;
} DnsMessageHeader;

6.3 DNS Question

1
2
3
4
5
typedef struct DnsMessageQuestion {
uint16_t type;
uint16_t class;
char *name;
} DnsMessageQuestion;

6.3 DNS Request && Response

1
2
3
4
5
6
7
8
9
10
typedef struct DnsRequest {
struct DnsMessageHeader header;
struct DnsMessageQuestion query;
} DnsRequest;

typedef struct DnsResponse {
struct DnsMessageHeader header;
struct DnsMessageQuestion query;
array_t *answer; /* array of struct DnsMessageAnswer */
} DnsResponse;

6.4 DNS 域名处理

6.4.1 域名构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int construct_dns_name(const char *domain, uint8_t *buf) {
int cnt = 0;
const char *p = domain;
while (*p) {
const char *base = p;
uint8_t len = 0;
while (*p && *p != '.') {
++len;
++p;
}

*buf++ = len;
memcpy(buf, base, len);
buf += len;
if (*p == '.') {
++p;
}
cnt += len + 1;
}
*buf = 0;
return cnt + 1;
}

construct_dns_name 实现了将默认的域名格式转换为 DNS 协议格式,比如:

6.4.2 域名解析

将 DNS 格式转换为普通字符串,需要注意处理压缩指针,即 DNS 报文中可能多次出现同一个域名(比如 question 和 answer 都含有 example.com.),于是为节省空间,DNS 协议引入了“域名压缩指针”。

1
2
3
4
5
6
7
if ((len & DOMAIN_PTR_MASK) == DOMAIN_PTR_MASK) {
uint16_t ptr = ((len & 0x3F) << 8) | buf[offset + 1];
...
offset = ptr;
len = buf[offset];
continue;
}
  • (len & 0x3F) << 8 把低 6 位放到高字节,配合 buf[offset + 1] 构成了完整的偏移地址;

6.5 DNS 报文解析

6.5.1 标志解析

1
2
3
4
5
6
7
8
9
10
void parse_dns_flags(DnsMessageHeaderFlags *flags, uint16_t uflags) {
flags->QR = (uflags >> 15) & 0x01;
flags->OPcode = (uflags >> 11) & 0x0F;
flags->AA = (uflags >> 10) & 0x01;
flags->TC = (uflags >> 9) & 0x01;
flags->RD = (uflags >> 8) & 0x01;
flags->RA = (uflags >> 7) & 0x01;
flags->Z = (uflags >> 4) & 0x07;
flags->RCODE = uflags & 0x0F;
}

从 16 bits 的标记位中提取内容;

6.5.2 解析头部

读取并处理前 12 个字节,注意需要通过 ntohs 函数把网络字节序(big-endian)转为主机字节序(little-endian);

6.5.3 解析整个请求

调用前两者。

6.5.4 解析 DNS 响应

比前者多了对问题和回答区域的解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
response->answer = array_init(sizeof(DnsMessageAnswer));

int total = response->header.ANCOUNT + response->header.NSCOUNT;
for (int i = 0; i < total; ++i) {
DnsMessageAnswer *answer = RR_init();
offset += parse_dns_name(answer->name, buffer, offset);

memcpy(&answer->type, buffer + offset, sizeof(uint16_t));
answer->type = ntohs(answer->type);
offset += 2;

memcpy(&answer->class, buffer + offset, sizeof(uint16_t));
answer->class = ntohs(answer->class);
offset += 2;

memcpy(&answer->ttl, buffer + offset, sizeof(uint32_t));
answer->ttl = ntohl(answer->ttl);
offset += 4;

uint16_t data_len;
memcpy(&data_len, buffer + offset, sizeof(uint16_t));
data_len = ntohs(data_len);
answer->rdlength = data_len;
offset += 2;

if (answer->type == DNS_TYPE_A) {
memcpy(&answer->rdata.a_record.ipv4_address, buffer + offset, data_len);
} else if (answer->type == DNS_TYPE_AAAA) {
memcpy(&answer->rdata.aaaa_record.ipv6_address, buffer + offset, data_len);
} else if (answer->type == DNS_TYPE_CNAME) {
answer->rdata.cname_record.cname = (char *)malloc(NAME_MAX_SIZE);
assert(answer->rdata.cname_record.cname);
parse_dns_name(answer->rdata.cname_record.cname, buffer, offset);
}

offset += data_len;
array_append(response->answer, answer);
}

6.6 DNS 报文构造

构造标志位,构造头部,注意需要通过 w_bytes16 函数进行网络字节序的转换:

1
2
3
4
5
void w_bytes16(uint8_t *b, uint16_t v) {
if (b == NULL) return;
b[0] = (v >> 8) & 0xff;
b[1] = (v) & 0xff;
}

注意问题中的域名 qname 是最早出现的,没必要进行域名压缩;

6.6.1 构造资源记录 RR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
int put_answer(DnsMessageAnswer *answer, uint8_t *buffer, int answer_offset, DnsNameOffsetEntry *compression_table, int *compression_count) {
assert(answer != NULL && buffer != NULL);

int offset = 0;
uint8_t *answer_buf = buffer + answer_offset;

char qname[NAME_MAX_SIZE] = {0};
parse_dns_name(qname, buffer, MSG_HEADER_SIZE);

if (strcasecmp(answer->name, qname) == 0) {
answer_buf[offset++] = 0xC0;
answer_buf[offset++] = 0x0C;
} else {
int name_len = construct_dns_name_with_compression(answer->name, answer_buf + offset, answer_offset + offset, compression_table, compression_count);
offset += name_len;
}

w_bytes16(answer_buf + offset, answer->type);
offset += 2;

w_bytes16(answer_buf + offset, answer->class);
offset += 2;

w_bytes32(answer_buf + offset, answer->ttl);
offset += 4;

int rdlength_pos = offset;
offset += 2; // 占位,稍后填写

int tmp_offset = 0;

switch (answer->type) {
case DNS_TYPE_A:
memcpy(answer_buf + offset, &answer->rdata.a_record.ipv4_address, 4);
tmp_offset = 4;
break;
case DNS_TYPE_AAAA:
memcpy(answer_buf + offset, &answer->rdata.aaaa_record.ipv6_address, 16);
tmp_offset = 16;
break;
case DNS_TYPE_CNAME:
tmp_offset = construct_dns_name_with_compression(answer->rdata.cname_record.cname, answer_buf + offset, answer_offset + offset, compression_table, compression_count);
break;
}

w_bytes16(answer_buf + rdlength_pos, tmp_offset);
offset += tmp_offset;

return offset;
}

需要注意对域名的压缩处理:

1
2
3
4
5
6
7
int construct_dns_name_with_compression(
const char *domain, // 域名字符串,例如 "www.example.com"
uint8_t *buf, // 目标 buffer,用于构造 DNS 报文
int buf_base_offset, // 当前 buffer 的起始偏移(用于计算压缩指针的绝对位置)
DnsNameOffsetEntry *entries,// 用于记录所有已编码的后缀及其偏移
int *entry_count // 入口和出口参数:当前 entries 中的数量
)

函数的流程主要是遍历整个域名字符串,检查是否有后缀可以复用。如果有则使用压缩指针,记录当前后缀位置相对整个 Buffer 的起始偏移。

6.7 DNS 资源记录管理

包括初始化资源记录,以及深拷贝一个资源记录(包括内部动态分配的资源) RR_dup ,资源记录释放;

6.8 一些辅助函数

w_bytes32 函数用于将主机字节序的整数转换为网络字节序(大端);

generate_random_id 函数用于生成随机 ID 用于 ID 转换部分;

case_insentive_strcmp 大小写不敏感比较;

七、线程池相关

线程池通过预先创建一组工作线程,然后复用这些线程处理多个任务,避免创建和销毁线程的开销。DNS Relay 中,主要用于处理多个用户客户端的并发请求。

使用了互斥锁,用于:

  1. 保护本地 DNS 表;
  2. 保护 DNS Cache ;
  3. 保护套接字操作;
  4. 保护请求计数器;

7.1 线程池数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 任务函数指针类型
typedef void *(*job_func)(void *);

// 任务结构
typedef struct job {
struct job *next;
job_func func;
void *arg;
} job_t;

// 任务队列
typedef struct jobqueue_ {
job_t *head;
job_t *tail;
uint32_t len;
} jobqueue_t;

// 线程池结构
typedef struct thpool_ {
pthread_t *threads;
uint32_t num_threads_working;
uint32_t num_threads_total;
pthread_mutex_t mutex;
pthread_cond_t cond;
pthread_cond_t wait_cond;
jobqueue_t job_queue;
volatile int is_alive;
} thpool_t;

// 线程池句柄(对外隐藏实现细节)
typedef struct thpool_ *threadpool;

7.2 线程池接口

7.2.1 线程池初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
threadpool thpool_init(uint32_t num) {
if (num == 0 || num > MAX_THREAD) num = MAX_THREAD;

thpool_t *thpool = (thpool_t *)malloc(sizeof(thpool_t));
assert(thpool != NULL);

thpool->threads = (pthread_t *)malloc(sizeof(pthread_t) * num);
thpool->num_threads_working = 0;
thpool->num_threads_total = num;
thpool->job_queue.head = NULL;
thpool->job_queue.tail = NULL;
thpool->job_queue.len = 0;
thpool->is_alive = 1;

pthread_mutex_init(&thpool->mutex, NULL);
pthread_cond_init(&thpool->cond, NULL);
pthread_cond_init(&thpool->wait_cond, NULL);

for (uint32_t i = 0; i < num; ++i) {
pthread_create(&thpool->threads[i], NULL, thread_worker, thpool);
}

return thpool;
}
  1. 分配线程池内存;
  2. 分配线程数组内存;
  3. 初始化任务队列;
  4. 初始化计数器;
  5. 初始化互斥锁和条件变量;
  6. 创建工作线程,并运行工作线程函数 thread_worker

7.2.2 工作线程函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static void *thread_worker(void *arg) {
thpool_t *thpool = (thpool_t *)arg;
job_t *job;

while (1) {
pthread_mutex_lock(&thpool->mutex);

while (thpool->job_queue.len == 0 && thpool->is_alive) {
pthread_cond_wait(&thpool->cond, &thpool->mutex);
}

if (!thpool->is_alive) {
pthread_mutex_unlock(&thpool->mutex);
break;
}

++thpool->num_threads_working;
job = jobqueue_pop(&thpool->job_queue);
pthread_mutex_unlock(&thpool->mutex);

if (job) {
job->func(job->arg);
free(job);
}

pthread_mutex_lock(&thpool->mutex);
--thpool->num_threads_working;
if (thpool->job_queue.len == 0 && thpool->num_threads_working == 0) {
pthread_cond_broadcast(&thpool->wait_cond);
}
pthread_mutex_unlock(&thpool->mutex);
}
return NULL;
}
  1. 获取互斥锁,保护共享数据;
  2. 如果任务队列为空,通过条件变量进行等待;
  3. 检查线程池是否还处于活跃状态,如果不是则退出;
  4. 增加工作线程计数,并从任务队列中获取任务,并释放互斥锁;
  5. 执行任务函数,同时释放任务结构体;
  6. 重新获取互斥锁,减少工作线程计数;
  7. 任务均完成则通知等待的线程;
  8. 释放互斥锁,继续循环;

7.2.3 添加任务

  1. 获取互斥锁,保护任务队列
  2. 创建新任务结构体,设置函数和参数
  3. 将任务添加到队列尾部
  4. 通过条件变量唤醒一个等待的工作线程
  5. 释放互斥锁

7.2.4 获取任务

其实就是一个对于任务队列的链表的操作:

  1. 获取队列头部的任务
  2. 更新队列头指针,如果队列变空也更新尾指针
  3. 减少队列长度计数
  4. 返回任务指针

7.2.5 等待任务完成

1
2
3
4
5
6
7
8
9
void thpool_wait(threadpool pool) {
thpool_t *thpool = (thpool_t *)pool;

pthread_mutex_lock(&thpool->mutex);
while (thpool->job_queue.len > 0 || thpool->num_threads_working > 0) {
pthread_cond_wait(&thpool->wait_cond, &thpool->mutex);
}
pthread_mutex_unlock(&thpool->mutex);
}
  1. 获取互斥锁
  2. 当任务队列非空或有工作线程在执行任务时,通过条件变量等待
  3. 当所有任务完成时,被工作线程唤醒
  4. 释放互斥锁

所有操作基本都是先获取互斥锁,最后进行释放。

7.2.6 销毁线程池

  1. 获取互斥锁,将线程池标记为不活跃
  2. 唤醒所有等待的工作线程,释放互斥锁
  3. 等待所有任务完成
  4. 等待所有工作线程退出
  5. 清理剩余的任务(如果有)
  6. 销毁同步原语和释放内存

DNS Relay Server 开发记录
https://blog.yokumi.cn/2025/06/01/DNS Relay Server 开发记录/
作者
Yokumi
发布于
2025年6月1日
许可协议
CC BY-NC-SA 4.0