本文将介绍如何使用 Nginx 作为一般 DDNS 程序的替代方案,完整配置在 200 行左右。

相比较使用一些充满“黑盒”依赖,或者运行时复杂的程序,使用 Nginx 可以以更低的资源,来完成我们所需要的效果。

写在前面

之前在群里提到过这个方案,出于篇幅的原因,这个话题将会拆解为几部分,分别介绍:

  1. 使用 Nginx 完成基础的 DDNS 核心操作,包括进行 DNS 记录更新。
  2. 改进架构,在云端完成这一切,让服务的“兼容性”更好。
  3. 使用 Nginx 来完成全私有化部署(包括 DNS )。

为了利于维护,尽可能简化和将操作清晰的持久化记录下来,本文将基于容器环境,所以你可以将其搭建在拥有 Docker 容器环境的设备上,包括群晖 NAS 设备等。

了解 DDNS 工作流程

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

抽象 DDNS 工作流程

本文作为第一篇文章,以公网环境为例,介绍如何编写一个轻量透明的 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