本文将介绍如何使用 Nginx 作为一般 DDNS 程序的替代方案,完整配置在 200 行左右。 相比较使用一些充满“黑盒”依赖,或者运行时复杂的程序,使用 Nginx 可以以更低的资源,来完成我们所需要的效果。

写在前面

之前在群里提到过这个方案,出于篇幅的原因,这个话题将会拆解为几部分,分别介绍: 1、使用 Nginx 完成基础的 DDNS 核心操作,包括进行 DNS 记录更新。 2、改进架构,在云端完成这一切,让服务的“兼容性”更好。 3、使用 Nginx 来完成全私有化部署(包括 DNS )。 为了利于维护,尽可能简化和将操作清晰的持久化记录下来,本文将基于容器环境,所以你可以将其搭建在拥有 Docker 容器环境的设备上,包括群晖 NAS 设备等。

了解 DDNS 工作流程

DDNS 服务服务整个工作流程非常简单,主要分为两个阶段,一个阶段为服务获取私网或公网的地址,并更新该网络环境的 DNS 解析记录。另外一个阶段则是用户请求该网络环境的 DNS 服务器,获取最新的地址,请求服务。

使用 Nginx 提供 DDNS 服务(前篇)_Java

本文作为第一篇文章,以公网环境为例,介绍如何编写一个轻量透明的 DDNS 服务。

使用 Nginx NJS 编写 DDNS 服务

前文中的工作流程部分介绍了 DDNS 的几个部分,接下来我们先来完成获取 IP 这部分操作。

编写 IP 获取逻辑

在编写获取 IP 逻辑之前,我们首先要选择一个能够返回 IP 的公开服务,我这里在网上随便搜索了一个服务(搜狐):

http://pv.sohu.com/cityjson?ie=utf-8

使用浏览器或者命令行请求该地址,可以得到类似下面的结果:

var returnCitySN = {"cip": "123.116.123.123", "cid": "110102", "cname": "北京市西城区"};

这个接口返回的内容比较多,因为我们只需要 IP 地址这部分数据,所以需要将数据摘出来:

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 配置进行修改,将外部地址使用反向代理的方式转变为服务内部地址。

location /internal/whatsmyip {

internal;

proxy_pass "http://pv.sohu.com/cityjson";

}

考虑到接口安全,我们将这个接口标记为 “internal” 避免产生服务之外的调用,避免出现恶意利用,导致外部接口封禁我们的正常请求,或者产生不必要的资源消耗。 完整 IP 查询功能后,我们接着来看看如何处理 DNS 记录。

编写 DNS 更新逻辑

这里以 Cloudflare DNS 为例,其他服务商大同小异:

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 能够对其进行调用:

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 配置文件可以这样编写:

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 命令启动服务,在浏览器或者命令行中访问服务地址,不出意外,你将会得到类似下面的结果:

{

"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 字段,让服务在出现包括服务器重启等异常情况下,能够保持自动运行,可以减少非常多的维护成本。

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 的玩法。