本文将介绍如何使用 Nginx 作为一般 DDNS 程序的替代方案,完整配置在 200 行左右。
相比较使用一些充满“黑盒”依赖,或者运行时复杂的程序,使用 Nginx 可以以更低的资源,来完成我们所需要的效果。
写在前面
之前在群里提到过这个方案,出于篇幅的原因,这个话题将会拆解为几部分,分别介绍:
- 使用 Nginx 完成基础的 DDNS 核心操作,包括进行 DNS 记录更新。
- 改进架构,在云端完成这一切,让服务的“兼容性”更好。
- 使用 Nginx 来完成全私有化部署(包括 DNS )。
为了利于维护,尽可能简化和将操作清晰的持久化记录下来,本文将基于容器环境,所以你可以将其搭建在拥有 Docker 容器环境的设备上,包括群晖 NAS 设备等。
了解 DDNS 工作流程
DDNS 服务服务整个工作流程非常简单,主要分为两个阶段,一个阶段为服务获取私网或公网的地址,并更新该网络环境的 DNS 解析记录。另外一个阶段则是用户请求该网络环境的 DNS 服务器,获取最新的地址,请求服务。
本文作为第一篇文章,以公网环境为例,介绍如何编写一个轻量透明的 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 的玩法。
–EOF