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

2021-07-31 18:33:16 浏览数 (1)

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

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

写在前面

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

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

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

了解 DDNS 工作流程

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

抽象 DDNS 工作流程抽象 DDNS 工作流程

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

使用 Nginx NJS 编写 DDNS 服务

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

编写 IP 获取逻辑

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

代码语言:txt复制
http://pv.sohu.com/cityjson?ie=utf-8

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

代码语言:txt复制
var returnCitySN = {"cip": "123.116.123.123", "cid": "110102", "cname": "北京市西城区"};

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

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

代码语言:txt复制
location /internal/whatsmyip {
    internal;
    proxy_pass "http://pv.sohu.com/cityjson";
}

考虑到接口安全,我们将这个接口标记为 “internal” 避免产生服务之外的调用,避免出现恶意利用,导致外部接口封禁我们的正常请求,或者产生不必要的资源消耗。

完整 IP 查询功能后,我们接着来看看如何处理 DNS 记录。

编写 DNS 更新逻辑

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

代码语言:txt复制
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 能够对其进行调用:

代码语言:txt复制
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 配置文件可以这样编写:

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

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

代码语言:txt复制
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


如果你觉得内容还算实用,欢迎点赞分享给你的朋友,在此谢过。


本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 署名 4.0 国际 (CC BY 4.0)

本文作者: 苏洋

创建时间: 2021年07月31日

统计字数: 6632字

阅读时间: 14分钟阅读

本文链接: https://soulteary.com/2021/07/31/use-nginx-to-provide-ddns-service-part-1.html

0 人点赞