我之前曾介绍如何使用 Go 语言自建 DoH 服务1并以 ZNS 品牌2对外提供服务。一年多以来确实积累了一些用户。但在使用的时候发现,阿里云香港的轻量服务器在网络高峰期线路质量太差,有用户提出希望能提供更稳定的线路。于是多数据中心部署 ZNS 便提上了日程。为了适应异地多活部署,我用 Nginx 重新实现了 DoH 代理服务,并跟现有的计费体系打通。本文向大家分享相关的经验。本文也亦可作为 Nginx 和 Shell 脚本进阶学习的参考资料。

ZNS 服务

发布 ZNS 的出发点很简单,我自己常看的网站 Hacker News 其域名被污染了。为了看一家网站开梯子🪜好像有点不值,另外我还有一台港区的阿里云轻量,为什么不自己建一套 DoH 服务呢?服务搞好之后一直自用,没啥大问题。后来想干脆对外发布,分享给有需要的朋友。于是给服务添加了计费逻辑3,便对外开张了,这便是 ZNS 服务。

很早就有用户提议希望能提供备用线路。因为之前 ZNS 所在的轻量服务器被 DDos 过一次,一直到第二天才解除黑洞路由🤦‍♂️这确实是个问题,DNS是所有服务的入口,稳定压倒一切。于是匆忙上马备线项目。

跨区更新方案

我自己在美西有一台 DMIT 服务器,也是本博客所在的主机,配置和线路都不错。但奈何上面部署的服务太多,历史包袱很重,改造起来比较困难。而且还涉及一个计费问题。用户的流量余额数据都在香港,如果使用备用线路,那该如何更新呢?一种方案是用分布式数据库,将香港的数据自动同步到美国,两边都可以读写。理论上这是最完美的方案。但是在实现层面肯定需要把写操作集中到一起,不然两边同时更新肯定会有冲突。这就会出现美区的流量需要更新到港区的数据库这种局面。还有一种方案就是按用户分区,让用户自己选在哪个区,选好之后数据也自动迁移过去,避免跨区读写。这种对于 ZNS 来说就太复杂,需要大改。

为了尽快实现备用线路,我选用了跨区更新方案。先在美西部署一套代理,收到 DoH 请求后发给香港的 ZNS 处理,自然会完成计费。如果香港的服务出问题了,则自动切换成本地解析,即将 DNS 请求发给 Google 解析,而且不计费。该方案简单粗暴,目的是解决从零到一的问题。我的设想是用户日常使用香港服务器,出问题了再切换到美国服务器。出问题时美国服务器免费使用,这就规避了跨区计费的问题。

但总得来说,以上备线方案还是太草台班子了。新的线路方案肯定要在支持计费的前提下还得不能额外引入接口延迟。于是我想出了异步计费方案💡

异步计费方案的核心是同步统计、异步更新。服务处理完用户的请求之后会记录下该次请求消耗的流量。不同的服务节点会定时统计过去一段时间所有相关用户的流量消耗,然后更新到中心计费服务器。各服务节点还需要定时从中心服务器更新用户列表,排除那些余额不足的用户。

于是便有了本文(终于把背景说完了💦)

DoH 代理

DoH 代理说白了就是普通的 HTTP 代理🤦‍♂️因为 DoH 就是用 HTTP 协议去承载 DNS 查询请求。我们需要做的就是把收到的 DoH 请求转发给 dns.google 这样的上游来解析就可以了。

所以最简单的 DoH 代理的 Nginx 配置如下:

server {
    listen [::]:443 ssl ipv6only=off default_server;
    listen [::]:443 quic ipv6only=off default_server;

    ssl_protocols TLSv1.3;
    ssl_prefer_server_ciphers on;

    http2 on;
    quic_retry on;
    ssl_early_data on;
    quic_gso on;

    add_header Alt-Svc 'h3=":443"';

    ssl_certificate /root/.acme.sh/2001:db8::_ecc/fullchain.cer;
    ssl_certificate_key /root/.acme.sh/2001:db8::_ecc/2001:db8::.key;

    server_name  _;

    location /dns-query {
        proxy_pass https://dns.google/dns-query;
    }
}

这里同时开启了 http/1.1, h2 和 h3 协议。另外这里还使用了 IP 地址 SSL 证书。感谢 Let’s Encrypt 已经支持签发 IP 地址证书了,如果你还不会操作请参考这篇文章4

DoH 代理功能就这些了。Nginx 收到路径为 /dns-query 的请求后都会转发给 dns.google 来处理。

接下来我们要处理鉴权与计费逻辑。

鉴权与计费

首先我们得识别用户。ZNS 给每个用户提供的 DoH 链接🔗是https://zns.lehu.in/dns/<token>。这里的<token>就是用户令牌,计费全靠他。

在 Nginx 中,我们可以通过正则来提取路径中的参数:

location ~ ^/dns/(?<doh_token>[^/]+) {
    rewrite ^.+$ /dns-query;
}

上面的规则会将 /dns/ 路径的请求内部转写成 /dns-query,在效果上跟用户直接访问 /dns-query 是一样的。但在请求处理过程中,Nginx 会把用户令牌保存到变量$doh_token,我们可以直接引用的。

所谓计费,就是记录什么用户消耗了多少流量。说白了就是 Nginx 的访问日志。不同的是我们需要调整日志格式,方便加工。我定义的 DoH 计费日志格式如下:

log_format doh '$time_iso8601 $remote_addr $doh_token '
               '$request_method $request_length $server_protocol $status $bytes_sent '
               '"$http_user_agent"';

一条典型的计费日志如下:

2025-12-20T05:38:21+00:00 ::ffff:223.104.79.110 <doh_token> GET 80 HTTP/2.0 200 503 "-"

核心变量有三个:

  • $doh_token 用户令牌
  • $request_length 查询请求字节数
  • $bytes_sent 查询响应字节数

有了这些数据,就可以定时更新用户余额信息了。当然了,不要忘记在对应的 location 开启日志。

location /dns-query {
    access_log /var/log/nginx/doh/doh.log doh;
    proxy_pass https://dns.google/dns-query;
}

以上是计费,还得加上鉴权功能才算完整。鉴权需要用到 Nginx 的 map 功能。

map $doh_token $doh_allow {
    foo     "on";
    bar     "off";
    include doh_tokens.conf;
    default "off";
}

这里我们通过 map 定义了从变量$doh_token$doh_allow的自动映射关系。在上例中,如果$doh_tokenfoo,那么对应的$doh_allow就是onbar则对应off。如果 map 中没找到对应的$doh_token值,$doh_allow就会取默认值off。最妙的是 map 支持通过include从外部文件中导入映射关系。上例中会自动加载 /etc/nginx/doh_tokens.conf 中的规则:

# map 映射关系
foo     "on";
bar     "off";

有了$doh_allow变量,我们就可以做鉴权了:

location ~ ^/dns/(?<doh_token>[^/]+) {
    if ($doh_allow != "on") {
        return 444 "";
    }

    rewrite ^.+$ /dns-query;
}

是不是很简单,就是通过if来检查$doh_allow的值,不符合要求就返回报错。正常返回 401 状态码就可以了。但我这里为了进一步节约流量,返回了非标的 444 状态码。大家有兴趣可以看我的另一篇文章,有相关介绍5

定时脚本

以上都是 Nginx 的静态功能。要想对接计费系统,还需要设置一批定时脚本。

为了简化实现,我直接通过 ssh 实现跨服操作。

更新 token 列表脚本:

#!/usr/bin/env bash

ssh zns@zns.lehu.in ./cat-tokens.sh|awk '{print $1, "on;"}' > /etc/nginx/doh_tokens.conf
nginx -s reload

这是登录到 zns.lehu.in 服务器并执行 cat-tokens.sh 脚本。该脚本会输出当前有效用户列表。然后通过管道传给 awk 构造 map 规则,最后保存到 doh_tokens.conf 配置文件中。

配置更新后需要调用nginx -s reload重新加载 token 列表。

各节点不关心 cat-tokens.sh 具体怎么实现,只要输出一列用户令牌就可以了。ZNS 使用 SQLite 保存数据,可以直接用 SQL 查询:

#!/usr/bin/env bash

today=$(date +"%Y-%m-%d")
sqlite3 /var/lib/zns/zns.db "select distinct token from tickets where bytes > 0 and expires > '$today' order by token"

更新计费脚本就比较复杂了。

awk '$7 == 200 {token=$3;bytes[token]+=$5+$8;}END{for (token in bytes) {print token,bytes[token];}}' $log2 | ssh zns@zns.lehu.in "./update-cost.sh"

简单来说就是通过 awk 处理 DoH 请求日志,将每个用户的流量消耗分别累加,然后由 SSH 传给 ZNS 的 update-cost.sh 处理。这里展开分析 awk 脚本。

awk '$7 == 200 {
        token=$3;
        bytes[token]+=$5+$8;
    }
    END {
        for (token in bytes) {
            print token,bytes[token];
        }
    }'

awk 先通过$7 == 200过滤出正常的 DoH 请求,然后提取第三列的 doh_token,再将第五列的请求流量和响应流量累加并保存到 bytes 字典变量中。都处理完成后执行 END 代码,依次输出每个令牌对应的流量消耗。输出示例如下:

foo 100
bar 200

如果你还不熟悉 awk 请一定不要错过我的这篇文章6

update-cost.sh 脚本主要功能是遍历 stdin 的每一行,提取 token 和流量,然后调用 sqlite 更新对应记录:

#!/usr/bin/env bash

today=$(date +"%Y-%m-%d")

while IFS=' ' read -r token bytes; do
    sql="UPDATE tickets SET bytes = bytes - $bytes * 4 WHERE rowid = (SELECT MIN(rowid) FROM tickets WHERE token = '$token' and expires > '$today' and bytes > 0);"
    sqlite3 /var/lib/zns/zns.db "$sql"
done

核心逻辑是 while 循环配合 read 命令。

最后说一下整体的更新逻辑。我们前面讲过,希望采用定时更新的办法来减少跨区读写频率,优化服务延迟。所以我暂定每10分钟统计并更新一次。

更新脚本需要每十分钟运行一次,提取当前 Nginx 所有的 DoH 日志,汇总流量消耗,然后发送到计费服务器。整个过程不重复处理,也不能漏处理。

我的思路是每次运行会确定当前所属的时段。比如14:33分会对齐到14:30。确定时间后将 Nginx 的日志重命名,加上时间段,然后通知 Nginx 重新创建新的日志。接下来汇总流量消耗并更新,更新完成后给该时段的日志文件加上特殊标记,确定不会重复处理。

整个脚本逻辑如下:

#!/usr/bin/env bash

log=/var/log/nginx/doh/doh.log

# 如果 Nginx 没有输出日志则不处理
[ ! -s "$log" ] && echo "DoH log file is empty." && exit 0

# 提取当前时间并对齐到最近的 10 分钟
m=$(date +'%M')
x=$(( $m / 10 * 10 ))

now=$(date +'%y%m%dT%H')$x

# 构造当前时段的日志文件路径
log2=$log.$now

# 如果当前时段已经处理过则退出,防止重复处理
[ -f "${log2}_" ] && echo "Log file processed." && exit 0

# 将当前日志文件改名,并通知 Nginx 创建新文件
[ ! -f "$log2" ] && mv $log $log2 && nginx -s reopen

# 汇总并更新
awk '$7 == 200 {token=$3;bytes[token]+=$5+$8;}END{for (token in bytes) {print token,bytes[token];}}' $log2 | ssh zns@zns.lehu.in "./update-cost.sh"

# 标记已经处理完成
mv $log2 ${log2}_

# 更新令牌列表
./update-tokens.sh

最后配置定时任务:

*/10 * * * * /root/zns/doh-cost.sh >> /root/zns/doh-cost.log

总结

本文介绍一套基于 Nginx + Shell 的分布式 DoH 计费服务方案。通过他,可以很方便的部署多可用区 DoH 服务并实现统一计费功能。这套方案也是践行 UNIX 「一次只做一件事,并把它做好」理念示例。同时也是很好的学习参考案例。


  1. ../dns/diy-doh.html↩︎

  2. https://zns.lehu.in↩︎

  3. ../go/bytes-counter.html↩︎

  4. ../unix/free-ssl-cert-for-ip.html↩︎

  5. ../homelab/hide-web.html↩︎

  6. ../awk-in-20-minutes.html↩︎