本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2021年07月31日 统计字数: 6653字 阅读时间: 14分钟阅读 本文链接: https://soulteary.com/2021/07/31/use-nginx-to-provide-ddns-service-part-1.html ----- # 使用 Nginx 提供 DDNS 服务(前篇) 本文将介绍如何使用 Nginx 作为一般 DDNS 程序的替代方案,完整配置在 200 行左右。 相比较使用一些充满“黑盒”依赖,或者运行时复杂的程序,使用 Nginx 可以以更低的资源,来完成我们所需要的效果。 ## 写在前面 之前在群里提到过这个方案,出于篇幅的原因,这个话题将会拆解为几部分,分别介绍: 1. 使用 Nginx 完成基础的 DDNS 核心操作,包括进行 DNS 记录更新。 2. 改进架构,在云端完成这一切,让服务的“兼容性”更好。 3. 使用 Nginx 来完成全私有化部署(包括 DNS )。 为了利于维护,尽可能简化和将操作清晰的持久化记录下来,本文将基于容器环境,所以你可以将其搭建在拥有 Docker 容器环境的设备上,包括群晖 NAS 设备等。 ## 了解 DDNS 工作流程 DDNS 服务服务整个工作流程非常简单,主要分为两个阶段,一个阶段为服务获取私网或公网的地址,并更新该网络环境的 DNS 解析记录。另外一个阶段则是用户请求该网络环境的 DNS 服务器,获取最新的地址,请求服务。 ![抽象 DDNS 工作流程](https://attachment.soulteary.com/2021/07/31/flow.png) 本文作为第一篇文章,以公网环境为例,介绍如何编写一个轻量透明的 DDNS 服务。 ## 使用 Nginx NJS 编写 DDNS 服务 前文中的工作流程部分介绍了 DDNS 的几个部分,接下来我们先来完成获取 IP 这部分操作。 ### 编写 IP 获取逻辑 在编写获取 IP 逻辑之前,我们首先要选择一个能够返回 IP 的公开服务,我这里在网上随便搜索了一个服务(搜狐): ```TeXT http://pv.sohu.com/cityjson?ie=utf-8 ``` 使用浏览器或者命令行请求该地址,可以得到类似下面的结果: ```js var returnCitySN = {"cip": "123.116.123.123", "cid": "110102", "cname": "北京市西城区"}; ``` 这个接口返回的内容比较多,因为我们只需要 IP 地址这部分数据,所以需要将数据摘出来: ```js function whatsMyIP(r) { return new Promise(function (resolve, reject) { r.subrequest('/internal/whatsmyip?ie=utf-8') .then(reply => { const ip = reply.responseBody.match(/(\d+\.){3}\d+/); if (!ip) return resolve("127.0.0.1"); return resolve(ip[0]); }) .catch(e => reject(e)); }) } ``` 这里我定义了一个简单的函数,使用 NJS 内置的子请求功能,请求一个内部接口,将上面内容中的 IP 地址摘取出来。因为 NJS 不能直接请求外部地址,所以还需要对 Nginx 配置进行修改,将外部地址使用反向代理的方式转变为服务内部地址。 ```TeXT location /internal/whatsmyip { internal; proxy_pass "http://pv.sohu.com/cityjson"; } ``` 考虑到接口安全,我们将这个接口标记为 “internal” 避免产生服务之外的调用,避免出现恶意利用,导致外部接口封禁我们的正常请求,或者产生不必要的资源消耗。 完整 IP 查询功能后,我们接着来看看如何处理 DNS 记录。 ### 编写 DNS 更新逻辑 这里以 Cloudflare DNS 为例,其他服务商大同小异: ```js const zoneId = process.env.DNS_ZONE_ID; const recordName = process.env.DNS_RECORD_NAME; function getRecordIds(r, zoneId, domain) { return new Promise(function (resolve, reject) { r.subrequest(`/client/v4/zones/${zoneId}/dns_records?name=${domain}`, { method: 'GET' }) .then(reply => { const response = JSON.parse(reply.responseBody); if (!response.success) { return reject(false); } const filtered = response.result.filter(item => { return item.name === domain }); if (filtered.length) { return resolve(filtered[0].id); } else { return resolve(false); } }) .catch(e => reject(e)); }); } function createRecordByName(r, zoneId, domain, clientIP) { return new Promise(function (resolve, reject) { const params = { type: "A", name: domain, content: clientIP, ttl: 120 }; r.subrequest(`/client/v4/zones/${zoneId}/dns_records`, { method: "POST", body: JSON.stringify(params) }) .then(reply => JSON.parse(reply.responseBody)) .then(response => { if (response.success) { return reject(JSON.stringify(response, null, 4)); } return resolve(true); }) .catch(e => reject(e)); }); } function updateExistRecord(r, zoneId, domain, recordId, clientIP) { return new Promise(function (resolve, reject) { const params = { id: recordId, type: "A", name: domain, content: clientIP, ttl: 120 }; r.subrequest(`/client/v4/zones/${zoneId}/dns_records/${recordId}`, { method: 'PUT', body: JSON.stringify(params) }) .then(reply => JSON.parse(reply.responseBody)) .then(response => { if (response.success) { return reject(JSON.stringify(response, null, 4)); } return resolve(true); }) .catch(e => reject(e)); }); } function whatsMyIP(r) { return new Promise(function (resolve, reject) { r.subrequest('/internal/whatsmyip?ie=utf-8') .then(reply => { const ip = reply.responseBody.match(/(\d+\.){3}\d+/); if (!ip) return resolve("127.0.0.1"); return resolve(ip[0]); }) .catch(e => reject(e)); }) } function main(r) { whatsMyIP(r).then(clientIP => { const domain = recordName; getRecordIds(r, zoneId, domain).then(recordId => { if (recordId) { updateExistRecord(r, zoneId, domain, recordId, clientIP).then(response => { r.return(200, response); }).catch(e => r.return(500, e)); } else { createRecordByName(r, zoneId, domain, clientIP).then(response => { r.return(200, response); }).catch(e => r.return(500, e)); } }).catch(e => r.return(500, e)); }).catch(e => r.return(500, e)); } export default { main } ``` 不同服务商 OPEN API 处理逻辑不同,Cloudflare 需要分别处理目标 DNS 不存在时的创建操作,目标 DNS 已经存在时的记录更新,所以这里大概需要 100 来行来处理整个逻辑。如果你使用的 DNS 服务商的 API 比较智能,或许只要 30~50 行即可。 将上面的内容保存为 app.js ,稍后使用。 和上文获取 IP 处理外部接口的方式一样,同样需要修改 Nginx 配置来确保 NJS 能够对其进行调用: ```bash load_module modules/ngx_http_js_module.so; user nginx; worker_processes auto; error_log /var/log/nginx/error.log notice; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; keepalive_timeout 65; gzip on; js_path "/etc/nginx/njs/"; js_import app from app.js; server { listen 80; server_name localhost; # Bind request to SOHU location /internal/whatsmyip { internal; proxy_pass "http://pv.sohu.com/cityjson"; } # Bind request to CF location /client/v4/ { internal; gunzip on; proxy_set_header "X-Auth-Email" "${DNS_CF_USER}"; proxy_set_header "X-Auth-Key" "${DNS_CF_TOKEN}"; proxy_set_header "Content-Type" "application/json"; proxy_pass "https://api.cloudflare.com/client/v4/"; } location / { default_type text/plain; js_content app.main; } } } ``` 因为 NJS 子请求无法设置请求头,所以我们需要借助 Nginx 的 `proxy_set_header` 指令来完成请求头中关于身份鉴权的要求。将上面的内容保存为 nginx.conf ,同样稍后使用。 ### 进行服务编排 考虑到可维护性,我将这里的内容抽象为环境变量,虽然 Nginx 默认不支持自定义变量,但是我们有不止一种方案可以让环境变量正常工作,比如使用官方目前推荐的模版替换方式。 服务使用的 Compose 配置文件可以这样编写: ```yaml version: "3" services: ngx-ddns-client: image: nginx:1.21.1-alpine ports: - 8080:80 volumes: - ./nginx.conf:/etc/nginx/templates/nginx.conf.template:ro - ./app.js/:/etc/nginx/njs/app.js:ro environment: - DNS_CF_USER=yourname@company.ltd - DNS_CF_TOKEN=YOUR_API_TOKEN - DNS_ZONE_ID=YOUR_ZONE_ID - DNS_RECORD_NAME=ngx-ddns.yourdomain.ltd - NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx/ - NGINX_ENTRYPOINT_QUIET_LOGS=1 networks: traefik: external: true ``` 这里使用最新版本的 Nginx 镜像,通过改变默认的模版处理输出路径,来完成对 Nginx 主配置文件内容的变更,让 Nginx 配置文件也支持从全局环境变量中读取数据。 将上面的内容保存为 `docker-compose.yml`,并使用你自己的 API Token 等数据替换配置中的内容,执行 `docker-compose up` 命令启动服务,在浏览器或者命令行中访问服务地址,不出意外,你将会得到类似下面的结果: ```json { "result": { "name": "ngx-ddns.yourdomain.ltd", "zone_name": "yourdomain.ltd", "proxiable": false, "id": "12345679fc46dbfd12343ed81234567", "proxied": false, "meta": { "auto_added": false, "managed_by_apps": false, "managed_by_argo_tunnel": false, "source": "primary" }, "zone_id": "12345674cfb123456755e71234567", "ttl": 120, "modified_on": "2021-07-30T14:38:33.73636Z", "created_on": "2021-07-24T17:26:58.21951Z", "content": "123.123.123.123", "type": "A", "locked": false }, "success": true, "errors": [ ], "messages": [ ] } ``` 至此,DDNS 服务的基础功能就就绪了,算上所有的配置文件不超过 200 行代码。 然而,我们对于 DDNS 服务的要求是运行稳定,并且能够不断保持 DNS 记录为最新的结果,所以还需要针对这个配置文件进行一些微调。 ### 借助容器健康检查完成最终配置 容器服务自带健康检查功能,这是一个根据一定频度和规则进行程序运行状态断言的功能。我们将健康检查的方式设置为调用“DNS”注册接口,调用频率设置为一个合理的数值(在不过频的情况下,相对低一些),并检查返回值是否健康,就能够实现“不断更新 DNS记录”的需求了。 同样的,添加 restart 字段,让服务在出现包括服务器重启等异常情况下,能够保持自动运行,可以减少非常多的维护成本。 ```yaml version: "3" services: ngx-ddns-client: image: nginx:1.21.1-alpine ... restart: always healthcheck: test: ["CMD", "curl", "--silent", "--fail", "http://localhost"] interval: 30s timeout: 5s retries: 3 ``` 在上面的配置中,我设置每 30 秒更新一次 DNS 记录,考虑到请求的是多个远程接口,这里设置请求超时时间为 5 秒,如果出现超时或者请求异常,则进行 3 次重试操作。 ## 最后 下一篇 Nginx DDNS 的文章中,我将继续介绍 Nginx 和 NJS 的玩法。 --EOF