├── CNAME
├── ddns
├── util
│ ├── __init__.py
│ ├── try_run.py
│ ├── comment.py
│ └── fileio.py
├── __builtins__.pyi
├── __init__.py
├── provider
│ ├── dnspod_com.py
│ ├── debug.py
│ ├── edgeone_dns.py
│ ├── he.py
│ ├── callback.py
│ ├── __init__.py
│ ├── dnscom.py
│ ├── _signature.py
│ ├── noip.py
│ ├── dnspod.py
│ ├── aliesa.py
│ └── cloudflare.py
├── config
│ └── env.py
├── scheduler
│ ├── __init__.py
│ ├── _base.py
│ ├── cron.py
│ ├── schtasks.py
│ ├── launchd.py
│ └── systemd.py
└── ip.py
├── setup.cfg
├── favicon.ico
├── doc
├── img
│ ├── ddns.png
│ └── ddns.svg
├── providers
│ ├── dnscom.md
│ ├── dnscom.en.md
│ ├── debug.md
│ ├── debug.en.md
│ ├── he.md
│ ├── namesilo.md
│ ├── noip.md
│ ├── aliesa.md
│ ├── dnspod.md
│ ├── 51dns.md
│ ├── dnspod_com.md
│ ├── edgeone_dns.md
│ ├── callback.md
│ └── README.md
├── examples
│ └── config-with-extra.json
├── install.md
└── install.en.md
├── tests
├── config
│ ├── debug.json
│ ├── he-proxies.json
│ ├── noip.json
│ ├── multi-provider.json
│ └── callback.json
├── __init__.py
├── base_test.py
├── scripts
│ ├── test-in-docker.sh
│ └── test-task-windows.bat
├── test_provider_dnspod_com.py
├── README.md
├── test_config_cli_extra.py
└── test_config_log_file_dir.py
├── .github
├── ISSUE_TEMPLATE
│ ├── other-issues.md
│ ├── feature_request.md
│ ├── debug.md
│ └── new-dns-provider.md
├── workflows
│ ├── auto-reply-issue.yml
│ ├── update-agents.yml
│ └── test-install.yml
├── prompts
│ └── issue-assistant.md
├── scripts
│ └── update_agents_structure.py
└── copilot-instructions.md
├── _config.yml
├── .vscode
├── extensions.json
└── settings.json
├── run.py
├── docker
├── entrypoint.sh
├── musl.Dockerfile
├── Dockerfile
└── glibc.Dockerfile
├── LICENSE
├── .gitignore
├── schema
└── v2.json
└── pyproject.toml
/CNAME:
--------------------------------------------------------------------------------
1 | ddns.newfuture.cc
--------------------------------------------------------------------------------
/ddns/util/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | universal = 1
3 |
--------------------------------------------------------------------------------
/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NewFuture/DDNS/HEAD/favicon.ico
--------------------------------------------------------------------------------
/doc/img/ddns.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NewFuture/DDNS/HEAD/doc/img/ddns.png
--------------------------------------------------------------------------------
/tests/config/debug.json:
--------------------------------------------------------------------------------
1 | {
2 | "dns": "debug",
3 | "index4": "default",
4 | "ipv4": "ddns.newfuture.cc"
5 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/other-issues.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Other Issues
3 | about: 其它问题
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
--------------------------------------------------------------------------------
/ddns/__builtins__.pyi:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | # flake8: noqa: F401
3 | # ruff: noqa: F403
4 | from typing import *
5 | from .provider import SimpleProvider
6 | import logging
7 |
--------------------------------------------------------------------------------
/doc/providers/dnscom.md:
--------------------------------------------------------------------------------
1 | ---
2 | redirect_to: /doc/providers/51dns.html
3 | ---
4 |
5 | # DNS.COM 配置指南
6 |
7 | **DNS.COM 已更名为 51DNS,正在重定向到新页面...**
8 |
9 | 如果页面未自动跳转,请点击:[51DNS 配置指南](51dns.md)
10 |
11 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | markdown: kramdown
2 |
3 | favicon: /doc/img/ddns.svg
4 | site_favicon: /doc/img/ddns.svg
5 |
6 | kramdown:
7 | parse_block_html: true
8 |
9 | plugins:
10 | - jekyll-mentions
11 | - jekyll-seo-tag
12 | - jekyll-sitemap
13 | - jekyll-redirect-from
14 |
--------------------------------------------------------------------------------
/ddns/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | ddns Package
4 | """
5 |
6 | __description__ = "automatically update DNS records to my IP [域名自动指向本机IP]"
7 |
8 | # 编译时,版本会被替换
9 | __version__ = "${BUILD_VERSION}"
10 |
11 | # 时间也会被替换掉
12 | build_date = "${BUILD_DATE}"
13 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "github.vscode-github-actions",
4 | "github.vscode-pull-request-github",
5 | "ms-python.python",
6 | "ms-python.vscode-pylance",
7 | "charliermarsh.ruff",
8 | "ms-azuretools.vscode-containers"
9 | ]
10 | }
--------------------------------------------------------------------------------
/doc/providers/dnscom.en.md:
--------------------------------------------------------------------------------
1 | ---
2 | redirect_to: /doc/providers/51dns.en.html
3 | ---
4 |
5 | # DNS.COM Configuration Guide
6 |
7 | **DNS.COM has been renamed to 51DNS, redirecting to the new page...**
8 |
9 | If the page doesn't redirect automatically, please click: [51DNS Configuration Guide](51dns.en.md)
10 |
--------------------------------------------------------------------------------
/ddns/provider/dnspod_com.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | DNSPOD Global (国际版) API
4 | http://www.dnspod.com/docs/domains.html
5 | @author: NewFuture
6 | """
7 |
8 | from .dnspod import DnspodProvider # noqa: F401
9 |
10 |
11 | class DnspodComProvider(DnspodProvider):
12 | """
13 | DNSPOD.com Provider (国际版)
14 | This class extends the DnspodProvider to use the global DNSPOD API.
15 | """
16 |
17 | endpoint = "https://api.dnspod.com"
18 | DefaultLine = "default"
19 |
--------------------------------------------------------------------------------
/doc/examples/config-with-extra.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
3 | "dns": "cloudflare",
4 | "id": "user@example.com",
5 | "token": "YOUR_API_TOKEN_HERE",
6 | "ipv4": [
7 | "example.com",
8 | "www.example.com"
9 | ],
10 | "index4": ["default"],
11 | "ttl": 600,
12 | "extra": {
13 | "proxied": true,
14 | "comment": "Managed by DDNS - Auto-updated",
15 | "tags": [
16 | "production",
17 | "ddns"
18 | ]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tests/config/he-proxies.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
3 | "dns": "he",
4 | "token": "test-token",
5 | "ipv4": [
6 | "ddns.newfuture.cc"
7 | ],
8 | "index4": "default",
9 | "ttl": 300,
10 | "cache": false,
11 | "proxy": [ //代理列表,轮换测试
12 | "http://invalid-proxy-1.example.com:8080",
13 | "http://invalid-proxy-2.example.com:8080",
14 | "SYSTEM",
15 | "DIRECT"
16 | ],
17 | "ssl": false,
18 | "endpoint": "http://httpbingo.org/base64/bm9jaGc=?"
19 | }
--------------------------------------------------------------------------------
/ddns/provider/debug.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | DebugProvider
4 | 仅打印出 IP 地址,不进行任何实际 DNS 更新。
5 | """
6 |
7 | from ._base import SimpleProvider
8 |
9 |
10 | class DebugProvider(SimpleProvider):
11 | def _validate(self):
12 | """无需任何验证"""
13 | pass
14 |
15 | def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
16 | self.logger.debug("DebugProvider: %s(%s) => %s", domain, record_type, value)
17 | ip_type = "IPv4" if record_type == "A" else "IPv6" if record_type == "AAAA" else record_type
18 | print("[{}] {}".format(ip_type, value))
19 | return True
20 |
--------------------------------------------------------------------------------
/tests/config/noip.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
3 | "dns": "noip", // NoIP DNS service
4 | "id": "TEST", // Unique identifier for the configuration
5 | "token": "TEST", # Token for authentication, can be empty for NoIP
6 | "endpoint": "https://httpbingo.org/base64/Z29vZCAxOTIuMTY4LjEuMQ==?",
7 | "ipv4": [
8 | "ddns.newfuture.cc"
9 | ],
10 | "index4": [
11 | "public" // 公网IP
12 | ],
13 | "ttl": 300,
14 | "proxy": null,
15 | "cache": false, // Disable caching for NoIP
16 | "ssl": false,
17 | "log": {
18 | "level": "DEBUG",
19 | "datefmt": "%m-%d %H:%M:%S"
20 | }
21 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: 新功能建议
4 | title: "[feature]"
5 | labels: feature
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 描述场景和问题 (Is your feature request related to a problem? Please describe)
11 |
12 |
13 | ## 解决方案或者思路 (Describe the solution you'd like)
14 |
15 |
16 | ## 考虑过的其他方案或者思路 (Describe alternatives you've considered)
17 |
18 |
19 | ## 补充说明 (Additional context)
20 |
21 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | DDNS Tests Package
4 | """
5 |
6 | import sys
7 | import os
8 | import unittest
9 |
10 | try:
11 | from unittest import mock # type: ignore
12 | from unittest.mock import patch, MagicMock, call
13 | except ImportError: # Python 2
14 | from mock import patch, MagicMock, call # type: ignore
15 | import mock # type: ignore
16 |
17 | __all__ = ["patch", "MagicMock", "unittest", "call", "mock"]
18 |
19 | # 添加当前目录到 Python 路径,这样就可以直接导入 test_base
20 | current_dir = os.path.dirname(__file__)
21 | if current_dir not in sys.path:
22 | sys.path.insert(0, current_dir)
23 |
24 | # 添加上级目录到 Python 路径,这样就可以导入 ddns 模块
25 | parent_dir = os.path.dirname(current_dir)
26 | if parent_dir not in sys.path:
27 | sys.path.insert(0, parent_dir)
28 |
--------------------------------------------------------------------------------
/tests/config/multi-provider.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
3 | "ssl": "auto",
4 | "cache": false,
5 | "log": {
6 | "level": "DEBUG"
7 | },
8 | "providers": [
9 | {
10 | "provider": "debug",
11 | "index4": "default",
12 | "ipv4": "test1.example.com",
13 | "ttl": 300
14 | },
15 | {
16 | "provider": "debug",
17 | "index4": [
18 | "public"
19 | ],
20 | "ipv4": [
21 | "test2.example.com"
22 | ],
23 | "ttl": 600,
24 | "cache": true,
25 | "endpoint": null,
26 | "log": {
27 | "level": "INFO"
28 | }
29 | }
30 | ]
31 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/debug.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: debug
3 | about: 出错和调试
4 | title: "[debug]"
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## **描述问题 (Describe the bug)**
11 |
12 |
13 |
14 |
15 | ## 版本信息 (version info)
16 |
17 | * OS Version:
18 | * Type(运行方式): Binary/Python2/Python3
19 | * related issues (相关问题):
20 | * ddns --version 输出:
21 |
22 | ```sh
23 |
24 | ```
25 |
26 | ## **复现步骤 (To Reproduce)**
27 |
28 |
29 | ### 配置 (config)
30 |
31 |
32 |
33 |
34 | ```json
35 | {
36 | }
37 | ```
38 |
39 | ### 调试输出 (debug output)
40 |
41 | 运行时,命令行加上`--debug`开启调试模式
42 |
43 |
44 | ```sh
45 |
46 | 粘贴输出日志,保留三个点的分割
47 | paste out put here
48 |
49 | ```
50 |
51 | ## 补充说明 (Additional context)
52 |
53 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/new-dns-provider.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: New DNS Provider
3 | about: 接入新DNS服务商
4 | title: "[dns]"
5 | labels: NewDNSProvider
6 | assignees: ''
7 | ---
8 |
9 | ## DNS 服务商信息
10 |
11 | - **官网 (Website)**:
12 | - **中文名称 (Chinese Name)**:
13 | - **英文名称 (English Name)**:
14 | - **标准DNS服务商 (Standard DNS Provider)**: Yes/No
15 |
16 | ---
17 |
18 | ## DNS 服务商文档链接
19 |
20 |
21 |
22 | 请提供以下相关文档的链接(如有):
23 |
24 | - [ ] **API 认证与签名** (Authorization & Signature):
25 | - [ ] **查询/列出域名** (Query/List Domains):
26 | - [ ] **查询/列出解析记录** (Query/List DNS Records):
27 | - [ ] **创建解析记录** (Create DNS Record):
28 | - [ ] **修改解析记录** (Update DNS Record):
29 | - [ ] **其它配置或使用文档** (Other Configuration/Usage Docs, 可选):
30 | - [ ] **官方或第三方Python SDK** (Official or Third-party Python SDK, 可选):
31 |
32 | ---
33 |
34 | ## 其他补充信息(可选)
35 |
36 | 请补充任何有助于集成该 DNS 服务商的信息,例如常见问题、注意事项、特殊限制等。
37 |
--------------------------------------------------------------------------------
/run.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding:utf-8 -*-
3 |
4 | # Nuitka Project Configuration
5 | # nuitka-project: --mode=onefile
6 | # nuitka-project: --output-filename=ddns
7 | # nuitka-project: --output-dir=dist
8 | # nuitka-project: --product-name=DDNS
9 | # nuitka-project: --product-version=0.0.0
10 | # nuitka-project: --onefile-tempdir-spec="{TEMP}/{PRODUCT}_{VERSION}"
11 | # nuitka-project: --no-deployment-flag=self-execution
12 | # nuitka-project: --company-name="NewFuture"
13 | # nuitka-project: --copyright=https://ddns.newfuture.cc
14 | # nuitka-project: --assume-yes-for-downloads
15 | # nuitka-project: --python-flag=no_site,no_asserts,no_docstrings,no_annotations,isolated,static_hashes
16 | # nuitka-project: --nofollow-import-to=tkinter,unittest,pydoc,doctest,distutils,setuptools,lib2to3,test,idlelib,lzma,bz2,csv
17 | # nuitka-project: --noinclude-dlls=liblzma.*
18 |
19 | from ddns.__main__ import main
20 |
21 | main()
22 |
--------------------------------------------------------------------------------
/ddns/provider/edgeone_dns.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | Tencent Cloud EdgeOne DNS API
4 | 腾讯云 EdgeOne (边缘安全速平台) DNS API - 非加速域名管理
5 | API Documentation: https://cloud.tencent.com/document/api/1552/80731
6 | @author: NewFuture
7 | """
8 |
9 | from .edgeone import EdgeOneProvider
10 |
11 |
12 | class EdgeOneDnsProvider(EdgeOneProvider):
13 | """
14 | 腾讯云 EdgeOne DNS API 提供商 - 用于管理非加速域名
15 | Tencent Cloud EdgeOne DNS API Provider - For managing non-accelerated DNS records
16 | """
17 |
18 | def __init__(self, id, token, logger=None, ssl="auto", proxy=None, endpoint=None, **options):
19 | # type: (str, str, object, bool|str, list[str]|None, str|None, **object) -> None
20 | """
21 | 初始化 EdgeOne DNS 提供商,自动设置 teoDomainType 为 "dns"
22 |
23 | Initialize EdgeOne DNS provider with teoDomainType set to "dns"
24 | """
25 | # 设置域名类型为 DNS 记录
26 | options["teoDomainType"] = "dns"
27 | super(EdgeOneDnsProvider, self).__init__(id, token, logger, ssl, proxy, endpoint, **options)
28 |
--------------------------------------------------------------------------------
/docker/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | if [ $# -eq 0 ]; then
4 | printenv > /etc/environment
5 | if [ -f /config.json ]; then
6 | echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
7 | if [ ! -f /ddns/config.json ]; then
8 | ln -s /config.json /ddns/config.json
9 | echo "WARNING: /ddns/config.json not found. Created symlink to /config.json."
10 | fi
11 | echo "WARNING: From v4.0.0, the working dir is /ddns/"
12 | echo "WARNING: Please map your host folder to /ddns/"
13 | echo "[old] -v /host/folder/config.json:/config.json"
14 | echo "[new] -v /host/folder/:/ddns/"
15 | echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
16 | fi
17 | # Use DDNS_CRON environment variable for cron schedule, default to every 5 minutes
18 | CRON_SCHEDULE="${DDNS_CRON:-*/5 * * * *}"
19 | echo "${CRON_SCHEDULE} cd /ddns && /bin/ddns" > /etc/crontabs/root
20 | /bin/ddns && echo "Cron daemon will run with schedule: ${CRON_SCHEDULE}" && exec crond -f
21 | else
22 | first=`echo $1 | cut -c1`
23 | if [ "$first" = "-" ]; then
24 | exec /bin/ddns $@
25 | else
26 | exec $@
27 | fi
28 | fi
29 |
--------------------------------------------------------------------------------
/tests/config/callback.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
3 | "dns": "callback",
4 | "id": "https://postman-echo.com/post?test=ddns&domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__×tamp=__TIMESTAMP__",
5 | "token": "{\"api_key\": \"test_e2e\", \"domain\": \"__DOMAIN__\", \"ip_address\": \"__IP__\", \"record_type\": \"__RECORDTYPE__\", \"ttl\": \"__TTL__\", \"line\": \"__LINE__\", \"timestamp\": \"__TIMESTAMP__\", \"event\": \"ddns_update\", \"version\": \"v4.0\", \"source\": \"ddns_client_e2e_test\"}",
6 | "ipv4": [
7 | "ddns.newfuture.cc"
8 | ],
9 | "index4": [
10 | "url:http://api.ipify.cn",
11 | "url:http://api.myip.la",
12 | "url:http://checkip.amazonaws.com",
13 | "url:http://icanhazip.com",
14 | "url:https://ifconfig.co/ip",
15 | "url:https://api.ipify.org",
16 | "url:https://ifconfig.me/ip"
17 | ],
18 | "ipv6": [],
19 | "index6": false,
20 | "ttl": 300,
21 | "line": "default",
22 | "proxy": null,
23 | "cache": false,
24 | "ssl": "auto",
25 | "log": {
26 | "file": null,
27 | "level": "DEBUG",
28 | "datefmt": "%H:%M:%S"
29 | }
30 | }
--------------------------------------------------------------------------------
/tests/base_test.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | Base test utilities and common imports for all provider tests
4 | @author: NewFuture
5 | """
6 |
7 | from __init__ import unittest, patch, MagicMock # noqa: F401 # Ensure the package is initialized
8 |
9 |
10 | class BaseProviderTestCase(unittest.TestCase):
11 | """Base test case class with common setup for all provider tests"""
12 |
13 | def setUp(self):
14 | """Set up common test fixtures"""
15 | self.id = "test_id"
16 | self.token = "test_token"
17 |
18 | def assertProviderInitialized(self, provider, expected_id=None, expected_token=None):
19 | """Helper method to assert provider is correctly initialized"""
20 | self.assertEqual(provider.id, expected_id or self.id)
21 | self.assertEqual(provider.token, expected_token or self.token)
22 |
23 | def mock_logger(self, provider):
24 | """Helper method to mock provider logger"""
25 | provider.logger = MagicMock()
26 | return provider.logger
27 |
28 |
29 | # Export commonly used imports for convenience
30 | __all__ = ["BaseProviderTestCase", "unittest", "patch", "MagicMock"]
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 - 2019 New Future, https://ddns.newfuture.cc/
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/docker/musl.Dockerfile:
--------------------------------------------------------------------------------
1 | # use the prebuid base builder to speed up: ghcr.io/newfuture/nuitka-buider:musl-master
2 | ARG BUILDER=base-builder
3 | ARG PYTHON_VERSION=3.8
4 | ARG NUITKA_VERSION=main
5 |
6 |
7 | # build with alpine3.12 (musl-libc 1.1.24)
8 | FROM python:${PYTHON_VERSION}-alpine3.12 AS base-builder
9 | RUN apk add --update --no-cache gcc ccache build-base ca-certificates patchelf \
10 | && update-ca-certificates \
11 | && rm -rf /var/lib/apt/lists/* /var/cache/* /tmp/* /var/log/*
12 |
13 | ARG NUITKA_VERSION
14 | RUN python3 -m pip install --no-cache-dir --prefer-binary \
15 | "https://github.com/Nuitka/Nuitka/archive/${NUITKA_VERSION}.zip" \
16 | --disable-pip-version-check \
17 | # --break-system-packages \
18 | && rm -rf /var/cache/* /tmp/* /var/log/* /root/.cache
19 | WORKDIR /app
20 |
21 |
22 | FROM ${BUILDER} AS builder
23 | COPY run.py .github/patch.py .
24 | COPY ddns ddns
25 | ARG GITHUB_REF_NAME
26 | ENV GITHUB_REF_NAME=${GITHUB_REF_NAME}
27 | RUN python3 patch.py
28 | RUN python3 -O -m nuitka run.py \
29 | --remove-output \
30 | --lto=yes
31 | RUN cp dist/ddns /bin/ddns && cp dist/ddns /ddns
32 |
33 |
34 | # export the binary
35 | FROM scratch AS export
36 | COPY --from=builder /ddns /ddns
37 |
--------------------------------------------------------------------------------
/ddns/util/try_run.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | Utility: Safe command execution wrapper used across the project.
4 | Provides a single try_run function with consistent behavior.
5 | """
6 |
7 | import subprocess
8 | import sys
9 |
10 |
11 | def try_run(command, logger=None, **kwargs):
12 | # type: (list, object, **object) -> str | None
13 | """Safely run a subprocess command and return decoded output or None on failure.
14 |
15 | Args:
16 | command (list): Command array to execute
17 | logger (object, optional): Logger instance for debug output
18 | **kwargs: Additional arguments passed to subprocess.check_output
19 |
20 | Returns:
21 | str or None: Command output as string, or None if command failed
22 |
23 | - Adds a default timeout=60s on Python 3 to avoid hangs
24 | - Decodes output as text via universal_newlines=True
25 | - Logs at debug level when logger is provided
26 | """
27 | try:
28 | if sys.version_info[0] >= 3 and "timeout" not in kwargs:
29 | kwargs["timeout"] = 60
30 | return subprocess.check_output(command, universal_newlines=True, **kwargs) # type: ignore
31 | except Exception as e: # noqa: BLE001 - broad for subprocess safety
32 | if logger is not None:
33 | try:
34 | logger.debug("Command failed: %s", e) # type: ignore
35 | except Exception:
36 | pass
37 | return None
38 |
--------------------------------------------------------------------------------
/.github/workflows/auto-reply-issue.yml:
--------------------------------------------------------------------------------
1 | name: Auto Reply to Issues
2 |
3 | on:
4 | issues:
5 | types: [labeled]
6 |
7 | permissions:
8 | issues: write
9 | contents: read
10 |
11 | # Limit concurrent runs to prevent rate limit issues
12 | concurrency:
13 | group: auto-reply-${{ github.event.issue.number }}
14 | cancel-in-progress: false
15 |
16 | jobs:
17 | auto-reply:
18 | # Only trigger when 'ai-reply' label is added; skip bot-created issues and issues already labeled 'copilot'
19 | if: |
20 | github.event.label.name == 'ai-reply' &&
21 | github.event.issue.user.login != 'github-actions[bot]' &&
22 | !contains(github.event.issue.labels.*.name, 'copilot')
23 | runs-on: ubuntu-latest
24 | # Increased timeout to 10 minutes to accommodate multiple API calls, file I/O, and network latency.
25 | timeout-minutes: 10
26 |
27 | steps:
28 | - uses: actions/checkout@v4
29 |
30 | - name: Multi-turn AI Response
31 | id: ai_response
32 | uses: actions/github-script@v8
33 | with:
34 | github-token: ${{ secrets.GITHUB_TOKEN }}
35 | script: |
36 | const fs = require('fs');
37 | const path = require('path');
38 | const script = require('./.github/scripts/issue-ai-response.js');
39 | await script({ github, context, core, fs, path });
40 | env:
41 | OPENAI_URL: ${{ vars.OPENAI_URL }}
42 | OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
43 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | # https://alpinelinux.org/releases/
2 | # https://hub.docker.com/_/alpine/tags
3 | ARG HOST_VERSION=3.20
4 | # prebuilt image to speed up to speed up: ghcr.io/newfuture/nuitka-buider:master
5 | ARG BUILDER=base-builder
6 | ARG PYTHON_VERSION=3.8
7 | ARG NUITKA_VERSION=main # 新增此行,定义构建参数并提供默认值
8 |
9 |
10 | # https://hub.docker.com/_/python/tags?name=3.8-alpine3.20
11 | FROM python:${PYTHON_VERSION}-alpine${HOST_VERSION} AS base-builder
12 | # RUN apk add --no-cache python3-dev py3-pip py3-cffi py3-zstandard py3-ordered-set patchelf clang ccache
13 | RUN apk add --no-cache patchelf gcc ccache libffi-dev build-base zstd-libs\
14 | && rm -rf /var/lib/apt/lists/* /var/cache/* /tmp/* /var/log/*
15 | ARG NUITKA_VERSION
16 | RUN python3 -m pip install --no-cache-dir --prefer-binary \
17 | "https://github.com/Nuitka/Nuitka/archive/${NUITKA_VERSION}.zip" \
18 | --disable-pip-version-check \
19 | --break-system-packages \
20 | && rm -rf /var/cache/* /tmp/* /var/log/* /root/.cache
21 | WORKDIR /app
22 |
23 | FROM alpine:${HOST_VERSION} AS base
24 | RUN find /lib /usr/lib /usr/local/lib -name '*.so*' | sed 's|.*/||' | awk '{print "--noinclude-dlls="$0}' > nuitka_exclude_so.txt
25 |
26 | FROM ${BUILDER} AS builder
27 | COPY run.py .
28 | COPY ddns ddns
29 | COPY --from=base /nuitka_exclude_so.txt nuitka_exclude_so.txt
30 | RUN python3 -O -m nuitka run.py \
31 | --remove-output \
32 | --lto=yes \
33 | $(cat nuitka_exclude_so.txt)
34 | RUN mkdir /output && cp dist/ddns /output/
35 | COPY docker/entrypoint.sh /output/
36 |
37 | FROM alpine:${HOST_VERSION}
38 | COPY --from=builder /output/* /bin/
39 | WORKDIR /ddns
40 | ENTRYPOINT [ "/bin/entrypoint.sh" ]
41 |
--------------------------------------------------------------------------------
/docker/glibc.Dockerfile:
--------------------------------------------------------------------------------
1 | # prebuilt image to speed up: ghcr.io/newfuture/nuitka-buider:glibc-master
2 | ARG BUILDER=base-builder
3 | ARG PYTHON_VERSION=3.8
4 | ARG NUITKA_VERSION=main
5 |
6 |
7 | FROM python:${PYTHON_VERSION}-slim-buster AS base-builder
8 | # 安装必要的依赖
9 | # Use Debian archive for buster
10 | RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list \
11 | && sed -i '/security.debian.org/d' /etc/apt/sources.list \
12 | && echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-check-valid-until
13 | RUN apt-get update && apt-get install -y --no-install-recommends \
14 | ccache \
15 | patchelf \
16 | build-essential \
17 | libffi-dev \
18 | clang \
19 | ca-certificates \
20 | && apt-get clean \
21 | && rm -rf /var/lib/apt/lists/* /var/cache/* /tmp/* /var/log/*
22 | # 安装Python依赖
23 | ARG NUITKA_VERSION
24 | RUN python3 -m pip install --no-cache-dir --prefer-binary \
25 | "https://github.com/Nuitka/Nuitka/archive/${NUITKA_VERSION}.zip" \
26 | --disable-pip-version-check \
27 | --break-system-packages \
28 | && rm -rf /var/cache/* /tmp/* /var/log/* /root/.cache
29 | WORKDIR /app
30 |
31 |
32 | FROM ${BUILDER} AS builder
33 | # 拷贝项目文件
34 | COPY run.py .github/patch.py doc/img/ddns.svg .
35 | COPY ddns ddns
36 | ARG GITHUB_REF_NAME
37 | ENV GITHUB_REF_NAME=${GITHUB_REF_NAME}
38 | RUN python3 patch.py
39 | # 构建二进制文件,glibc arm下编译会报错,
40 | RUN python3 -O -m nuitka run.py \
41 | --remove-output \
42 | --linux-icon=ddns.svg \
43 | $( [ "$(uname -m)" = "aarch64" ] || echo --lto=yes )
44 | RUN cp dist/ddns /bin/ddns && cp dist/ddns /ddns
45 |
46 |
47 | # export the binary
48 | FROM scratch AS export
49 | COPY --from=builder /ddns /ddns
50 |
--------------------------------------------------------------------------------
/ddns/provider/he.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | Hurricane Electric (he.net) API
4 | @author: NN708, NewFuture
5 | """
6 |
7 | from ._base import SimpleProvider, TYPE_FORM
8 |
9 |
10 | class HeProvider(SimpleProvider):
11 | endpoint = "https://dyn.dns.he.net"
12 | content_type = TYPE_FORM
13 | accept = None # he.net does not require a specific Accept header
14 | decode_response = False # he.net response is plain text, not JSON
15 |
16 | def _validate(self):
17 | self.logger.warning(
18 | "HE.net 缺少充分的真实环境测试,请及时在 GitHub Issues 中反馈: %s",
19 | "https://github.com/NewFuture/DDNS/issues",
20 | )
21 | if self.id:
22 | raise ValueError("Hurricane Electric (he.net) does not use `id`, use `token(password)` only.")
23 | if not self.token:
24 | raise ValueError("Hurricane Electric (he.net) requires `token(password)`.")
25 |
26 | def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
27 | """
28 | 使用 POST API 更新或创建 DNS 记录。Update or create DNS record with POST API.
29 | https://dns.he.net/docs.html
30 | """
31 | self.logger.info("%s => %s(%s)", domain, value, record_type)
32 | params = {
33 | "hostname": domain, # he.net requires full domain name
34 | "myip": value, # IP address to update
35 | "password": self.token, # Use token as password
36 | }
37 | try:
38 | res = self._http("POST", "/nic/update", body=params)
39 | if res and res[:5] == "nochg" or res[:4] == "good": # No change or success
40 | self.logger.info("HE API response: %s", res)
41 | return True
42 | else:
43 | self.logger.error("HE API error: %s", res)
44 | except Exception as e:
45 | self.logger.error("Error updating record for %s: %s", domain, e)
46 | return False
47 |
--------------------------------------------------------------------------------
/.github/workflows/update-agents.yml:
--------------------------------------------------------------------------------
1 | # Workflow to check AGENTS.md directory structure
2 | # Runs daily and creates an issue if changes are detected
3 |
4 | name: Update AGENTS.md
5 |
6 | on:
7 | schedule:
8 | # Run daily at 00:00 UTC
9 | - cron: '0 0 * * *'
10 | workflow_dispatch:
11 | # Allow manual triggering
12 |
13 | permissions:
14 | contents: read
15 | issues: write
16 |
17 | # Prevent concurrent runs
18 | concurrency:
19 | group: update-agents-md
20 | cancel-in-progress: true
21 |
22 | jobs:
23 | check-agents:
24 | runs-on: ubuntu-latest
25 | timeout-minutes: 5
26 |
27 | steps:
28 | - name: Checkout repository
29 | uses: actions/checkout@v4
30 | with:
31 | fetch-depth: 1
32 |
33 | - name: Set up Python
34 | uses: actions/setup-python@v5
35 | with:
36 | python-version: '3.x'
37 |
38 | - name: Run comparison script
39 | run: python3 .github/scripts/update_agents_structure.py
40 |
41 | - name: Check for existing issue
42 | if: hashFiles('.github/issue_body.md') != ''
43 | id: existing
44 | run: |
45 | existing_issue=$(gh issue list --label "agents-structure-update" --state open --limit 1 --json number --jq '.[0].number // empty')
46 | if [ -n "$existing_issue" ]; then
47 | echo "exists=true" >> $GITHUB_OUTPUT
48 | echo "issue_number=$existing_issue" >> $GITHUB_OUTPUT
49 | else
50 | echo "exists=false" >> $GITHUB_OUTPUT
51 | fi
52 | env:
53 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54 |
55 | - name: Create issue
56 | if: hashFiles('.github/issue_body.md') != '' && steps.existing.outputs.exists == 'false'
57 | run: |
58 | gh issue create \
59 | --title "docs(agents): AGENTS.md directory structure needs update" \
60 | --body-file .github/issue_body.md \
61 | --label "documentation,automated,agents-structure-update"
62 | env:
63 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
64 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.analysis.autoFormatStrings": true,
3 | "python.analysis.autoImportCompletions": true,
4 | "python.analysis.completeFunctionParens": true,
5 | "python.analysis.supportAllPythonDocuments": true,
6 | "python.analysis.generateWithTypeAnnotation": true,
7 | "python.analysis.indexing": true,
8 | "python.analysis.aiCodeActions": {
9 | "implementAbstractClasses": true,
10 | "addMissingImports": true,
11 | "addMissingOptionalParameters": true,
12 | "addMissingReturn": true,
13 | "addMissingParameters": true,
14 | "addMissingTypeHints": true,
15 | "addMissingFunctionOverloads": true,
16 | "addMissingFunctionOverloadsWithReturnType": true
17 | },
18 | "python.testing.unittestArgs": [
19 | "-v",
20 | "-s",
21 | "./tests",
22 | "-p",
23 | "test_*.py"
24 | ],
25 | "python.testing.pytestEnabled": true,
26 | "python.testing.unittestEnabled": true,
27 | "[python]": {
28 | "editor.defaultFormatter": "charliermarsh.ruff",
29 | "editor.codeActionsOnSave": {
30 | "source.organizeImports.ruff": "explicit",
31 | "source.fixAll.ruff": "explicit"
32 | }
33 | },
34 | "editor.formatOnSave": true,
35 | "editor.formatOnSaveMode": "modificationsIfAvailable",
36 | "editor.codeActionsOnSave": {},
37 | "editor.bracketPairColorization.enabled": true,
38 | "editor.rulers": [
39 | 80,
40 | 100,
41 | 120
42 | ],
43 | "chat.agent.maxRequests": 64,
44 | "chat.tools.terminal.autoApprove": {
45 | "python": true,
46 | "python -c": true,
47 | "python -m": true,
48 | "python -m unittest": true,
49 | "python3": true,
50 | "python3 -c": true,
51 | "python3 -m": true,
52 | "python3 -m unittest": true,
53 | "echo": true,
54 | "cd": true,
55 | "ls": true,
56 | "dir": true,
57 | "git": true,
58 | "git status": true,
59 | "rm": true,
60 | "del": true,
61 | "delete": true,
62 | "mv": true,
63 | "move": true,
64 | },
65 | "github.copilot.chat.agent.currentEditorContext.enabled": true
66 | }
--------------------------------------------------------------------------------
/ddns/util/comment.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | Comment removal utility for JSON configuration files.
4 | Supports both # and // style single line comments.
5 | @author: GitHub Copilot
6 | """
7 |
8 |
9 | def remove_comment(content):
10 | # type: (str) -> str
11 | """
12 | 移除字符串中的单行注释。
13 | 支持 # 和 // 两种注释风格。
14 |
15 | Args:
16 | content (str): 包含注释的字符串内容
17 |
18 | Returns:
19 | str: 移除注释后的字符串
20 |
21 | Examples:
22 | >>> remove_comment('{"key": "value"} // comment')
23 | '{"key": "value"} '
24 | >>> remove_comment('# This is a comment\\n{"key": "value"}')
25 | '\\n{"key": "value"}'
26 | """
27 | if not content:
28 | return content
29 |
30 | lines = content.splitlines()
31 | cleaned_lines = []
32 |
33 | for line in lines:
34 | # 移除行内注释,但要小心不要破坏字符串内的内容
35 | cleaned_line = _remove_line_comment(line)
36 | cleaned_lines.append(cleaned_line)
37 |
38 | return "\n".join(cleaned_lines)
39 |
40 |
41 | def _remove_line_comment(line):
42 | # type: (str) -> str
43 | """
44 | 移除单行中的注释部分。
45 |
46 | Args:
47 | line (str): 要处理的行
48 |
49 | Returns:
50 | str: 移除注释后的行
51 | """
52 | # 检查是否是整行注释
53 | stripped = line.lstrip()
54 | if stripped.startswith("#") or stripped.startswith("//"):
55 | return ""
56 |
57 | # 查找行内注释,需要考虑字符串内容
58 | in_string = False
59 | quote_char = None
60 | i = 0
61 |
62 | while i < len(line):
63 | char = line[i]
64 |
65 | # 处理字符串内的转义序列
66 | if in_string and char == "\\" and i + 1 < len(line):
67 | i += 2 # 跳过转义字符
68 | continue
69 |
70 | # 处理引号字符
71 | if char in ('"', "'"):
72 | if not in_string:
73 | in_string = True
74 | quote_char = char
75 | elif char == quote_char:
76 | in_string = False
77 | quote_char = None
78 |
79 | # 在字符串外检查注释标记
80 | elif not in_string:
81 | if char == "#":
82 | return line[:i].rstrip()
83 | elif char == "/" and i + 1 < len(line) and line[i + 1] == "/":
84 | return line[:i].rstrip()
85 |
86 | i += 1
87 |
88 | return line
89 |
--------------------------------------------------------------------------------
/ddns/config/env.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | Configuration loader for DDNS environment variables.
4 | @author: NewFuture
5 | """
6 |
7 | from ast import literal_eval
8 | from os import environ
9 | from sys import stderr
10 |
11 | __all__ = ["load_config"]
12 |
13 |
14 | def _try_parse_array(value, key=None):
15 | # type: (str, str|None) -> Any
16 | """解析数组值[], 非数组返回元素字符串(去除前后空格)"""
17 | if not value:
18 | return value
19 |
20 | value = value.strip()
21 | # 尝试解析 JSON
22 | # if (value.startswith("{'") and value.endswith("'}")) or (value.startswith('{"') and value.endswith('"}')):
23 | # try:
24 | # return json_decode(value)
25 | # except Exception:
26 | # logging.warning("Failed to parse JSON from value: %s", value)
27 | if value.startswith("[") and value.endswith("]"):
28 | # or (value.startswith("{") and value.endswith("}"))
29 | try:
30 | return literal_eval(value)
31 | except Exception:
32 | stderr.write("Failed to parse JSON array from value: {}={}\n".format(key, value))
33 | pass
34 | # 返回去除前后空格的字符串
35 | return value
36 |
37 |
38 | def load_config(prefix="DDNS_"):
39 | # type: (str) -> dict
40 | """
41 | 从环境变量加载配置并返回配置字典。
42 |
43 | 支持以下转换:
44 | 1. 对于特定的数组参数(index4, index6, ipv4, ipv6, proxy),转换为数组
45 | 2. 对于 JSON 格式的数组 [item1,item2],转换为数组
46 | 3. 键名转换:点号转下划线,支持大小写变体
47 | 4. 自动检测标准 Python 环境变量:
48 | - SSL 验证:PYTHONHTTPSVERIFY
49 | 5. 支持 extra 字段:DDNS_EXTRA_XXX 会被转换为 extra_xxx
50 | 6. 其他所有值保持原始字符串格式,去除前后空格
51 |
52 | Args:
53 | prefix (str): 环境变量前缀,默认为 "DDNS_"
54 |
55 | Returns:
56 | dict: 从环境变量解析的配置字典
57 | """
58 | env_vars = {} # type: dict[str, str | list]
59 |
60 | # 标准环境变量映射
61 | alias_mappings = {"pythonhttpsverify": "ssl"}
62 |
63 | for key, value in environ.items():
64 | lower_key = key.lower()
65 | config_key = None
66 |
67 | if lower_key in alias_mappings:
68 | config_key = alias_mappings[lower_key]
69 | if config_key in env_vars:
70 | continue # DDNS变量优先级更高
71 | elif lower_key.startswith(prefix.lower()):
72 | config_key = lower_key[len(prefix) :].replace(".", "_") # noqa: E203
73 |
74 | if config_key:
75 | env_vars[config_key] = _try_parse_array(value, key=key)
76 |
77 | return env_vars
78 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | config.*.json
2 |
3 | *.temp
4 |
5 | # Generated files from workflows
6 | .github/issue_body.md
7 |
8 | # Byte-compiled / optimized / DLL files
9 | __pycache__/
10 | *.py[cod]
11 | *$py.class
12 |
13 | # C extensions
14 | *.so
15 |
16 | # Distribution / packaging
17 | .Python
18 | env/
19 | build/
20 | develop-eggs/
21 | dist/
22 | downloads/
23 | eggs/
24 | .eggs/
25 | lib/
26 | lib64/
27 | parts/
28 | sdist/
29 | var/
30 | *.egg-info/
31 | .installed.cfg
32 | *.egg
33 |
34 | # PyInstaller
35 | # Usually these files are written by a python script from a template
36 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
37 | *.manifest
38 | *.spec
39 | !.build/ddns.spec
40 |
41 | # Installer logs
42 | pip-log.txt
43 | pip-delete-this-directory.txt
44 |
45 | # Unit test / coverage reports
46 | htmlcov/
47 | .tox/
48 | .coverage
49 | .coverage.*
50 | .cache
51 | nosetests.xml
52 | coverage.xml
53 | *,cover
54 | .hypothesis/
55 |
56 | # Translations
57 | *.mo
58 | *.pot
59 |
60 | # Django stuff:
61 | *.log
62 | local_settings.py
63 |
64 | # Flask instance folder
65 | instance/
66 |
67 | # Scrapy stuff:
68 | .scrapy
69 |
70 | # Sphinx documentation
71 | docs/_build/
72 |
73 | # PyBuilder
74 | target/
75 |
76 | # IPython Notebook
77 | .ipynb_checkpoints
78 |
79 | # pyenv
80 | .python-version
81 |
82 | # celery beat schedule file
83 | celerybeat-schedule
84 |
85 | # dotenv
86 | .env
87 |
88 | # virtualenv
89 | venv/
90 | ENV/
91 |
92 | # Spyder project settings
93 | .spyderproject
94 |
95 | # Rope project settings
96 | .ropeproject
97 |
98 | # =========================
99 | # Operating System Files
100 | # =========================
101 |
102 | # OSX
103 | # =========================
104 |
105 | .DS_Store
106 | .AppleDouble
107 | .LSOverride
108 |
109 | # Thumbnails
110 | ._*
111 |
112 | # Files that might appear in the root of a volume
113 | .DocumentRevisions-V100
114 | .fseventsd
115 | .Spotlight-V100
116 | .TemporaryItems
117 | .Trashes
118 | .VolumeIcon.icns
119 |
120 | # Directories potentially created on remote AFP share
121 | .AppleDB
122 | .AppleDesktop
123 | Network Trash Folder
124 | Temporary Items
125 | .apdisk
126 |
127 | # Windows
128 | # =========================
129 |
130 | # Windows image file caches
131 | Thumbs.db
132 | ehthumbs.db
133 |
134 | # Folder config file
135 | Desktop.ini
136 |
137 | # Recycle Bin used on file shares
138 | $RECYCLE.BIN/
139 |
140 | # Windows Installer files
141 | *.cab
142 | *.msi
143 | *.msm
144 | *.msp
145 |
146 | # Windows shortcuts
147 | *.lnk
148 | config.json
149 |
150 | .venv
--------------------------------------------------------------------------------
/.github/prompts/issue-assistant.md:
--------------------------------------------------------------------------------
1 | # DDNS Issue Assistant
2 |
3 | Automated single-reply issue assistant. Follow the strict 3-step protocol.
4 |
5 | ## Essentials
6 |
7 | - Assume reasonable details, mention unknowns, avoid stalling.
8 | - Project facts: Python-only Dynamic DNS client; updates IPv4/IPv6 records for many providers; ships via pip, Docker, binaries; Python 2.7+ compatible.
9 |
10 | ## Workflow (3 Steps)
11 |
12 | 1. Request files (<=10) to analyze the issue.
13 | 2. Provide final response OR request more files (choose ONE).
14 | 3. Must respond with final answer.
15 |
16 | ## JSON Output
17 |
18 | **Request files:**
19 | ```json
20 | {"requested_files":["path/one","path/two"]}
21 | ```
22 |
23 | **Final response:**
24 | ```json
25 | {"classification":"bug|feature|question","response":"markdown answer"}
26 | ```
27 | > Response should be <4096 tokens (<8000 max).
28 |
29 | ## Project Architecture
30 |
31 | Request the files you need to obtain the details.
32 |
33 | {{DirectoryStructure}}
34 |
35 | ### Features Overview
36 |
37 | - Three-level config priority: CLI args > JSON config files > environment variables.
38 |
39 | - Multiple IP detection methods (`ddns.ip`):
40 | network interface index / default route IP / public IP via API / custom URL / regex on ifconfig output / custom command or shell
41 |
42 | - Platform-specific schedulers:
43 | - Linux: systemd timers (default) or cron
44 | - macOS: launchd (default) or cron
45 | - Windows: Task Scheduler (`schtasks`)
46 | - Docker: built-in cron with configurable intervals
47 |
48 | ## Classification Playbook
49 |
50 | ### BUG
51 |
52 | - Diagnose root causes (providers, IP detection, caching, config).
53 | - Suggest fixes and debugging steps (log checks).
54 | - Note configuration errors or system-specific issues.
55 |
56 | ### FEATURE
57 |
58 | - Welcome the idea, weigh against constraints (stdlib-only, existing providers, schema rules).
59 | - For new providers: assess API docs, auth methods, endpoints; outline implementation if feasible.
60 | - Note workarounds and implement plan.
61 |
62 | ### QUESTION
63 |
64 | - Answer directly using docs or code, link sources.
65 | - Provide config/command examples.
66 | - References: `doc/config/*.md`, `doc/providers/*.md`, `README(.en).md`, `schema/v4.1.json`.
67 |
68 | ## Response Guidelines
69 |
70 | - Match user's language, use Markdown with code blocks.
71 | - Link docs/code: `[doc/providers/aliyun.md](https://ddns.newfuture.cc/doc/providers/aliyun.html)`
72 | - Cover plausible scenarios, call out assumptions.
73 | - If info missing: explain what's needed (`ddns --debug`, logs, repro steps).
74 | - Ensure the JSON you output is valid.
75 |
--------------------------------------------------------------------------------
/doc/install.md:
--------------------------------------------------------------------------------
1 | # 一键安装脚本
2 |
3 | DDNS 一键安装脚本,支持 Linux 和 macOS 系统自动下载安装。
4 |
5 | ## 快速安装
6 |
7 | ```bash
8 | # 在线安装最新稳定版
9 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh
10 | # 如需 root 权限安装到系统目录,使用 sudo
11 | curl -fsSL https://ddns.newfuture.cc/install.sh | sudo sh
12 |
13 | # 或使用 wget
14 | wget -qO- https://ddns.newfuture.cc/install.sh | sh
15 |
16 | ```
17 |
18 | > **说明:** 默认安装到 `/usr/local/bin`,如果该目录需要管理员权限,脚本会自动提示使用 sudo,或者可以预先使用 sudo 运行。
19 |
20 | ## 版本选择
21 |
22 | ```bash
23 | # 安装最新稳定版
24 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- latest
25 |
26 | # 安装最新测试版
27 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- beta
28 |
29 | # 安装指定版本
30 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- v4.0.2
31 | ```
32 |
33 | ## 命令行选项
34 |
35 | | 选项 | 说明 |
36 | |------|------|
37 | | `latest` | 安装最新稳定版(默认) |
38 | | `beta` | 安装最新测试版 |
39 | | `v4.0.2` | 安装指定版本 |
40 | | `--install-dir PATH` | 指定安装目录(默认:/usr/local/bin) |
41 | | `--proxy URL` | 指定代理域名前缀(例如:`https://hub.gitmirror.com/`),覆盖自动探测 |
42 | | `--force` | 强制重新安装 |
43 | | `--uninstall` | 卸载已安装的 ddns |
44 | | `--help` | 显示帮助信息 |
45 |
46 | ## 高级用法
47 |
48 | ```bash
49 | # 自定义安装目录
50 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- beta --install-dir ~/.local/bin
51 |
52 | # 强制重新安装
53 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- --force
54 |
55 | # 卸载
56 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- --uninstall
57 |
58 | # 指定代理域名(覆盖自动探测)
59 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- --proxy https://hub.gitmirror.com/
60 | ```
61 |
62 | ## 系统支持
63 |
64 | **操作系统:** Linux(glibc/musl)、macOS
65 | **架构:** x86_64、ARM64、ARM v7、ARM v6、i386
66 | **依赖:** curl 或 wget
67 |
68 | ### 自动检测功能
69 | - **系统检测:** 自动识别操作系统、架构和 libc 类型
70 | - **工具检测:** 自动选择 curl 或 wget 下载工具
71 | - **网络优化:** 自动测试并选择最佳下载镜像(github.com → 国内镜像站)
72 | - **手动覆盖:** 通过 `--proxy` 指定代理域名/镜像前缀,优先于自动探测
73 |
74 | ## 验证安装
75 |
76 | ```bash
77 | ddns --version # 检查版本
78 | which ddns # 检查安装位置
79 | ```
80 |
81 | ## 更新与卸载
82 |
83 | ```bash
84 | # 更新到最新版本
85 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- latest
86 |
87 | # 卸载
88 | curl -fsSL https://ddns.newfuture.cc/install.sh | sh -s -- --uninstall
89 |
90 | # 手动卸载
91 | sudo rm -f /usr/local/bin/ddns
92 | ```
93 |
94 | ## 故障排除
95 |
96 | **权限问题:** 使用 `sudo` 或安装到用户目录
97 | **网络问题:** 脚本自动使用镜像站点(hub.gitmirror.com、proxy.gitwarp.com 等)
98 | **架构不支持:** 查看 [releases 页面](https://github.com/NewFuture/DDNS/releases) 确认支持的架构
99 | **代理环境:** 脚本会尊重系统代理设置(`HTTP_PROXY/HTTPS_PROXY`);也可以使用 `--proxy https://hub.gitmirror.com/` 指定 GitHub 镜像前缀(覆盖自动探测)
100 |
--------------------------------------------------------------------------------
/ddns/scheduler/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | Task scheduler management
4 | Provides factory functions and public API for task scheduling
5 | @author: NewFuture
6 | """
7 |
8 | import os
9 | import platform
10 |
11 | from ddns.util.fileio import read_file_safely
12 |
13 | from ..util.try_run import try_run
14 |
15 | # Import all scheduler classes
16 | from ._base import BaseScheduler
17 | from .cron import CronScheduler
18 | from .launchd import LaunchdScheduler
19 | from .schtasks import SchtasksScheduler
20 | from .systemd import SystemdScheduler
21 |
22 |
23 | def get_scheduler(scheduler=None):
24 | # type: (str | None) -> BaseScheduler
25 | """
26 | Factory function to get appropriate scheduler based on platform or user preference
27 |
28 | Args:
29 | scheduler: Scheduler type. Can be:
30 | - None or "auto": Auto-detect based on platform
31 | - "systemd": Use systemd timer (Linux)
32 | - "cron": Use cron jobs (Unix/Linux)
33 | - "launchd": Use launchd (macOS)
34 | - "schtasks": Use Windows Task Scheduler
35 |
36 | Returns:
37 | Appropriate scheduler instance
38 |
39 | Raises:
40 | ValueError: If invalid scheduler specified
41 | NotImplementedError: If scheduler not available on current platform
42 | """
43 | # Auto-detect if not specified
44 | if scheduler is None or scheduler == "auto":
45 | system = platform.system().lower()
46 | if system == "windows":
47 | return SchtasksScheduler()
48 | elif system == "darwin": # macOS
49 | # Check if launchd directories exist
50 | launchd_dirs = ["/Library/LaunchDaemons", "/System/Library/LaunchDaemons"]
51 | if any(os.path.isdir(d) for d in launchd_dirs):
52 | return LaunchdScheduler()
53 | elif system == "linux" and (
54 | (read_file_safely("/proc/1/comm", default="").strip().lower() == "systemd")
55 | or (try_run(["systemctl", "--version"]) is not None)
56 | ): # Linux with systemd available
57 | return SystemdScheduler()
58 | return CronScheduler() # Other Unix-like systems, use cron
59 | elif scheduler == "systemd":
60 | return SystemdScheduler()
61 | elif scheduler == "cron":
62 | return CronScheduler()
63 | elif scheduler == "launchd" or scheduler == "mac":
64 | return LaunchdScheduler()
65 | elif scheduler == "schtasks" or scheduler == "windows":
66 | return SchtasksScheduler()
67 | else:
68 | raise ValueError("Invalid scheduler: {}. ".format(scheduler))
69 |
70 |
71 | # Export public API
72 | __all__ = ["get_scheduler", "BaseScheduler"]
73 |
--------------------------------------------------------------------------------
/doc/providers/debug.md:
--------------------------------------------------------------------------------
1 | # Debug Provider 配置指南
2 |
3 | ## 概述
4 |
5 | Debug Provider 是一个专门用于调试和测试的虚拟 DNS 服务商。它模拟 DNS 记录更新过程,但不进行任何查询修改操作,只是将相关信息输出到控制台,帮助开发者调试 DDNS 配置和功能。
6 |
7 | 官网链接:
8 |
9 | - 项目主页:[DDNS 项目](https://github.com/NewFuture/DDNS)
10 | - 开发文档:[Provider 开发指南](../dev/provider.md)
11 |
12 | ### 重要提示
13 |
14 | - Debug Provider **仅用于调试和测试**,不会进行任何实际的 DNS 更新操作
15 | - 只会将检测到的 IP 地址和域名信息打印到控制台
16 | - 适合用于验证配置文件格式和 IP 地址检测功能
17 |
18 | ## 认证信息
19 |
20 | Debug Provider 不需要任何认证信息,无需配置 `id` 和 `token` 参数。
21 |
22 | ```json
23 | {
24 | "dns": "debug" // 仅需指定服务商为 debug
25 | }
26 | ```
27 |
28 | ## 完整配置示例
29 |
30 | ```json
31 | {
32 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
33 | "dns": "debug", // 当前服务商
34 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
35 | "index6": "public", // IPv6地址来源
36 | "ipv4": ["ddns.newfuture.cc"], // IPv4 域名
37 | "ipv6": ["ipv6.ddns.newfuture.cc"], // IPv6 域名
38 | "cache": false, // 建议关闭缓存以便调试
39 | "log": {
40 | "level": "debug", // 日志级别
41 | }
42 | }
43 | ```
44 |
45 | ### 参数说明
46 |
47 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
48 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
49 | | dns | 服务商标识 | 字符串 | `debug` | 无 | 服务商参数 |
50 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
51 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
52 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
53 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
54 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `false` | 公用配置 |
55 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
56 |
57 | > **参数类型说明**:
58 | >
59 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
60 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数
61 |
62 | ## 命令行使用
63 |
64 | ```sh
65 | ddns --debug
66 | ```
67 |
68 | ### 指定参数
69 |
70 | ```sh
71 | ddns --dns debug --index4=0 --ipv4=ddns.newfuture.cc --debug
72 | ```
73 |
74 | ### 输出Log
75 |
76 | ```log
77 | INFO DebugProvider: ddns.newfuture.cc(A) => 192.168.1.100
78 | ```
79 |
80 | ### 错误模拟
81 |
82 | Debug Provider 也会模拟一些常见错误场景,帮助测试错误处理逻辑。
83 |
84 | ## 故障排除
85 |
86 | ### 常见问题
87 |
88 | - **无输出信息**:检查日志级别设置,确保启用了 DEBUG 级别
89 | - **IP 检测失败**:检查网络连接和 IP 来源配置
90 | - **配置格式错误**:使用 JSON 验证工具检查配置文件格式
91 |
92 | ## 支持与资源
93 |
94 | - [DDNS 项目文档](../../README.md)
95 | - [配置文件格式说明](../config/json.md)
96 | - [命令行使用指南](../config/cli.md)
97 | - [开发者指南](../dev/provider.md)
98 |
--------------------------------------------------------------------------------
/doc/img/ddns.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/scripts/test-in-docker.sh:
--------------------------------------------------------------------------------
1 | set -ex
2 | platform=${1:-linux/amd64}
3 | libc=${2:-musl}
4 | filePath=${3:-dist/ddns}
5 |
6 | volume=$(dirname $(realpath "$filePath"))
7 | file=$(basename "$filePath")
8 | MAP_CONF="${GITHUB_WORKSPACE}/tests/config:/config"
9 |
10 | if [ "$libc" = "glibc" ]; then
11 | container="ubuntu:19.04"
12 | else
13 | case $platform in
14 | linux/amd64)
15 | container="openwrt/rootfs:x86_64"
16 | ;;
17 | linux/386 | linux/i386)
18 | container="openwrt/rootfs:i386_pentium4"
19 | platform="linux/i386_pentium4"
20 | ;;
21 | linux/arm64)
22 | container="openwrt/rootfs:aarch64_generic"
23 | platform="linux/aarch64_generic"
24 | ;;
25 | linux/arm/v8)
26 | container="openwrt/rootfs:armsr-armv8"
27 | platform="linux/aarch64_generic"
28 | ;;
29 | linux/arm/v7)
30 | container="openwrt/rootfs:armsr-armv7"
31 | platform="linux/arm_cortex-a15_neon-vfpv4"
32 | ;;
33 | linux/arm/v6)
34 | # v6 不支持直接测试,需要qume仿真
35 | echo "::warn::untested platform '$platform' ($libc)"
36 | exit 0
37 | ;;
38 | *)
39 | container="alpine"
40 | ;;
41 | esac
42 | fi
43 |
44 | docker run --rm -v="$volume:/dist" --platform=$platform $container /dist/$file -h
45 | docker run --rm -v="$volume:/dist" --platform=$platform $container /dist/$file --version
46 | docker run --rm -v="$volume:/dist" --platform=$platform $container sh -c "/dist/$file || test -f config.json"
47 | docker run --rm -v="$volume:/dist" -v="$MAP_CONF" --platform=$platform $container /dist/$file -c /config/callback.json
48 | docker run --rm -v="$volume:/dist" -v="$MAP_CONF" --platform=$platform $container /dist/$file -c /config/debug.json
49 | docker run --rm -v="$volume:/dist" -v="$MAP_CONF" --platform=$platform $container /dist/$file -c /config/noip.json
50 |
51 | # Test task subcommand
52 | echo "Testing task subcommand..."
53 | docker run --rm -v="$volume:/dist" --platform=$platform $container /dist/$file task --help
54 | docker run --rm -v="$volume:/dist" --platform=$platform $container /dist/$file task --status
55 |
56 | # Test task functionality - auto-detect available scheduler
57 | echo "Testing task management with auto-detection..."
58 | TEST_SCRIPTS=$(dirname $(realpath "$0"))
59 |
60 | # Determine if privileged mode is needed for systemd support
61 | if [ "$libc" = "glibc" ]; then
62 | # Skip task test in glibc environment due to systemd requiring privileged container
63 | echo "Skipping task test in glibc environment (systemd requires privileged container)."
64 | else
65 | echo "Running task test cron..."
66 | docker run --rm -v="$volume:/dist" -v="$TEST_SCRIPTS:/scripts" \
67 | --platform=$platform $container /scripts/test-task-cron.sh /dist/$file
68 | fi
69 |
70 |
71 | # delete to avoid being reused
72 | docker image rm $container
73 |
--------------------------------------------------------------------------------
/ddns/provider/callback.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | Custom Callback API
4 | 自定义回调接口解析操作库
5 |
6 | @author: 老周部落, NewFuture
7 | """
8 |
9 | from ._base import TYPE_JSON, SimpleProvider
10 | from time import time
11 | from json import loads as jsondecode
12 |
13 |
14 | class CallbackProvider(SimpleProvider):
15 | """
16 | 通用自定义回调 Provider,支持 GET/POST 任意接口。
17 | Generic custom callback provider, supports GET/POST arbitrary API.
18 | """
19 |
20 | endpoint = "" # CallbackProvider uses id as URL, no fixed API endpoint
21 | content_type = TYPE_JSON
22 | decode_response = False # Callback response is not JSON, it's a custom response
23 |
24 | def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
25 | """
26 | 发送自定义回调请求,支持 GET/POST
27 | Send custom callback request, support GET/POST
28 | """
29 | self.logger.info("%s => %s(%s)", domain, value, record_type)
30 | url = self.id # 直接用 id 作为 url
31 | token = self.token # token 作为 POST 参数
32 | extra.update(
33 | {
34 | "__DOMAIN__": domain,
35 | "__RECORDTYPE__": record_type,
36 | "__TTL__": ttl,
37 | "__IP__": value,
38 | "__TIMESTAMP__": time(),
39 | "__LINE__": line,
40 | }
41 | )
42 | url = self._replace_vars(url, extra)
43 | method, params = "GET", None
44 | if token:
45 | # 如果有 token,使用 POST 方法
46 | method = "POST"
47 | # POST 方式,token 作为 POST 参数
48 | params = token if isinstance(token, dict) else jsondecode(token)
49 | for k, v in params.items():
50 | if hasattr(v, "replace"): # 判断是否支持字符串替换, 兼容py2,py3
51 | params[k] = self._replace_vars(v, extra)
52 |
53 | try:
54 | res = self._http(method, url, body=params)
55 | if res is not None:
56 | self.logger.info("Callback result: %s", res)
57 | return True
58 | else:
59 | self.logger.warning("Callback received empty response.")
60 | except Exception as e:
61 | self.logger.error("Callback failed: %s", e)
62 | return False
63 |
64 | def _replace_vars(self, string, mapping):
65 | # type: (str, dict) -> str
66 | """
67 | 替换字符串中的变量为实际值
68 | Replace variables in string with actual values
69 | """
70 | for k, v in mapping.items():
71 | string = string.replace(k, str(v))
72 | return string
73 |
74 | def _validate(self):
75 | # CallbackProvider uses id as URL, not as regular ID
76 | if self.endpoint or (not self.id or "://" not in self.id):
77 | # 如果 endpoint 已经设置,或者 id 不是有效的 URL,则抛出异常
78 | self.logger.critical("endpoint [%s] or id [%s] 必须是有效的URL", self.endpoint, self.id)
79 | raise ValueError("endpoint or id must be configured with URL")
80 |
--------------------------------------------------------------------------------
/tests/test_provider_dnspod_com.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | Unit tests for DnspodComProvider
4 |
5 | @author: GitHub Copilot
6 | """
7 |
8 | from base_test import BaseProviderTestCase, unittest
9 | from ddns.provider.dnspod_com import DnspodComProvider
10 |
11 |
12 | class TestDnspodComProvider(BaseProviderTestCase):
13 | """Test cases for DnspodComProvider"""
14 |
15 | def setUp(self):
16 | """Set up test fixtures"""
17 | super(TestDnspodComProvider, self).setUp()
18 | self.id = "test_email@example.com"
19 | self.token = "test_token"
20 |
21 | def test_class_constants(self):
22 | """Test DnspodComProvider class constants"""
23 | self.assertEqual(DnspodComProvider.endpoint, "https://api.dnspod.com")
24 | self.assertEqual(DnspodComProvider.DefaultLine, "default")
25 |
26 | def test_init_with_basic_config(self):
27 | """Test DnspodComProvider initialization with basic configuration"""
28 | provider = DnspodComProvider(self.id, self.token)
29 | self.assertEqual(provider.id, self.id)
30 | self.assertEqual(provider.token, self.token)
31 | self.assertEqual(provider.endpoint, "https://api.dnspod.com")
32 |
33 | def test_inheritance_from_dnspod(self):
34 | """Test that DnspodComProvider properly inherits from DnspodProvider"""
35 | from ddns.provider.dnspod import DnspodProvider
36 |
37 | provider = DnspodComProvider(self.id, self.token)
38 | self.assertIsInstance(provider, DnspodProvider)
39 | # Should have inherited methods from parent
40 | self.assertTrue(hasattr(provider, "_request"))
41 | self.assertTrue(hasattr(provider, "_query_zone_id"))
42 | self.assertTrue(hasattr(provider, "_query_record"))
43 | self.assertTrue(hasattr(provider, "_create_record"))
44 | self.assertTrue(hasattr(provider, "_update_record"))
45 |
46 |
47 | class TestDnspodComProviderIntegration(BaseProviderTestCase):
48 | """Integration test cases for DnspodComProvider"""
49 |
50 | def setUp(self):
51 | """Set up test fixtures"""
52 | super(TestDnspodComProviderIntegration, self).setUp()
53 | self.id = "test_email@example.com"
54 | self.token = "test_token"
55 |
56 | def test_api_endpoint_difference(self):
57 | """Test that DnspodComProvider uses different API endpoint than DnspodProvider"""
58 | from ddns.provider.dnspod import DnspodProvider
59 |
60 | dnspod_provider = DnspodProvider(self.id, self.token)
61 | dnspod_com_provider = DnspodComProvider(self.id, self.token)
62 |
63 | # Should use different API endpoints
64 | self.assertNotEqual(dnspod_provider.endpoint, dnspod_com_provider.endpoint)
65 | self.assertEqual(dnspod_com_provider.endpoint, "https://api.dnspod.com")
66 |
67 | def test_default_line_setting(self):
68 | """Test that DnspodComProvider uses correct default line"""
69 | provider = DnspodComProvider(self.id, self.token)
70 | self.assertEqual(provider.DefaultLine, "default")
71 |
72 |
73 | if __name__ == "__main__":
74 | unittest.main()
75 |
--------------------------------------------------------------------------------
/ddns/scheduler/_base.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | Base scheduler class for DDNS task management
4 | @author: NewFuture
5 | """
6 |
7 | import sys
8 | from datetime import datetime
9 | from logging import Logger, getLogger # noqa: F401
10 |
11 | from .. import __version__ as version
12 |
13 |
14 | class BaseScheduler(object):
15 | """Base class for all task schedulers"""
16 |
17 | def __init__(self, logger=None): # type: (Logger | None) -> None
18 | self.logger = (logger or getLogger()).getChild("task")
19 |
20 | def _get_ddns_cmd(self): # type: () -> list[str]
21 | """Get DDNS command for scheduled execution as array"""
22 | if hasattr(sys.modules["__main__"], "__compiled__"):
23 | return [sys.argv[0]]
24 | else:
25 | return [sys.executable, "-m", "ddns"]
26 |
27 | def _build_ddns_command(self, ddns_args=None): # type: (dict | None) -> list[str]
28 | """Build DDNS command with arguments as array"""
29 | # Get base command as array
30 | cmd_parts = self._get_ddns_cmd()
31 |
32 | if not ddns_args:
33 | return cmd_parts
34 |
35 | # Filter out debug=False to reduce noise
36 | args = {k: v for k, v in ddns_args.items() if not (k == "debug" and not v)}
37 |
38 | for key, value in args.items():
39 | if isinstance(value, bool):
40 | cmd_parts.extend(["--{}".format(key), str(value).lower()])
41 | elif isinstance(value, list):
42 | for item in value:
43 | cmd_parts.extend(["--{}".format(key), str(item)])
44 | else:
45 | cmd_parts.extend(["--{}".format(key), str(value)])
46 |
47 | return cmd_parts
48 |
49 | def _quote_command_array(self, cmd_array): # type: (list[str]) -> str
50 | """Convert command array to properly quoted command string"""
51 | return " ".join('"{}"'.format(arg) if " " in arg else arg for arg in cmd_array)
52 |
53 | def _get_description(self): # type: () -> str
54 | """Generate standard description/comment for DDNS installation"""
55 | date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
56 | return "auto-update v{} installed on {}".format(version, date)
57 |
58 | def is_installed(self): # type: () -> bool
59 | """Check if DDNS task is installed"""
60 | raise NotImplementedError
61 |
62 | def get_status(self): # type: () -> dict
63 | """Get detailed status information"""
64 | raise NotImplementedError
65 |
66 | def install(self, interval, ddns_args=None): # type: (int, dict | None) -> bool
67 | """Install DDNS scheduled task"""
68 | raise NotImplementedError
69 |
70 | def uninstall(self): # type: () -> bool
71 | """Uninstall DDNS scheduled task"""
72 | raise NotImplementedError
73 |
74 | def enable(self): # type: () -> bool
75 | """Enable DDNS scheduled task"""
76 | raise NotImplementedError
77 |
78 | def disable(self): # type: () -> bool
79 | """Disable DDNS scheduled task"""
80 | raise NotImplementedError
81 |
--------------------------------------------------------------------------------
/ddns/provider/__init__.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | from ._base import SimpleProvider
3 | from .alidns import AlidnsProvider
4 | from .aliesa import AliesaProvider
5 | from .callback import CallbackProvider
6 | from .cloudflare import CloudflareProvider
7 | from .debug import DebugProvider
8 | from .dnscom import DnscomProvider
9 | from .dnspod import DnspodProvider
10 | from .dnspod_com import DnspodComProvider
11 | from .edgeone import EdgeOneProvider
12 | from .edgeone_dns import EdgeOneDnsProvider
13 | from .he import HeProvider
14 | from .huaweidns import HuaweiDNSProvider
15 | from .namesilo import NamesiloProvider
16 | from .noip import NoipProvider
17 | from .tencentcloud import TencentCloudProvider
18 |
19 | __all__ = ["SimpleProvider", "get_provider_class"]
20 |
21 |
22 | def get_provider_class(provider_name):
23 | # type: (str) -> type[SimpleProvider]
24 | """
25 | 获取指定的DNS提供商类
26 |
27 | :param provider_name: 提供商名称
28 | :return: 对应的DNS提供商类
29 | """
30 | provider_name = str(provider_name).lower()
31 | mapping = {
32 | # dnspod.cn
33 | "dnspod": DnspodProvider,
34 | "dnspod_cn": DnspodProvider, # 兼容旧的dnspod_cn
35 | # dnspod.com
36 | "dnspod_com": DnspodComProvider,
37 | "dnspod_global": DnspodComProvider, # 兼容旧的dnspod_global
38 | # tencent cloud dnspod
39 | "tencentcloud": TencentCloudProvider,
40 | "tencent": TencentCloudProvider, # 兼容tencent
41 | "qcloud": TencentCloudProvider, # 兼容qcloud
42 | # tencent cloud edgeone (accelerated domains)
43 | "edgeone": EdgeOneProvider,
44 | "edgeone_acc": EdgeOneProvider, # 加速域名
45 | "teo_acc": EdgeOneProvider, # 加速域名别名
46 | "teo": EdgeOneProvider, # 兼容旧版本 (不在文档中提示)
47 | # tencent cloud edgeone dns (non-accelerated domains)
48 | "edgeone_dns": EdgeOneDnsProvider, # DNS记录管理
49 | "teo_dns": EdgeOneDnsProvider, # DNS记录管理别名
50 | "edgeone_noacc": EdgeOneDnsProvider, # 非加速域名
51 | # cloudflare
52 | "cloudflare": CloudflareProvider,
53 | # aliyun alidns
54 | "alidns": AlidnsProvider,
55 | "aliyun": AlidnsProvider, # 兼容aliyun
56 | # aliyun esa
57 | "aliesa": AliesaProvider,
58 | "esa": AliesaProvider, # 兼容esa
59 | # dns.com
60 | "dnscom": DnscomProvider,
61 | "51dns": DnscomProvider, # 兼容51dns
62 | "dns_com": DnscomProvider, # 兼容dns_com
63 | # he.net
64 | "he": HeProvider,
65 | "he_net": HeProvider, # 兼容he.net
66 | # huawei
67 | "huaweidns": HuaweiDNSProvider,
68 | "huawei": HuaweiDNSProvider, # 兼容huawei
69 | "huaweicloud": HuaweiDNSProvider,
70 | # namesilo
71 | "namesilo": NamesiloProvider,
72 | "namesilo_com": NamesiloProvider, # 兼容namesilo.com
73 | # no-ip
74 | "noip": NoipProvider,
75 | "no-ip": NoipProvider, # 兼容no-ip
76 | "noip_com": NoipProvider, # 兼容noip.com
77 | # callback
78 | "callback": CallbackProvider,
79 | "webhook": CallbackProvider, # 兼容
80 | "http": CallbackProvider, # 兼容
81 | # debug
82 | "print": DebugProvider,
83 | "debug": DebugProvider, # 兼容print
84 | }
85 | return mapping.get(provider_name) # type: ignore[return-value]
86 |
--------------------------------------------------------------------------------
/.github/workflows/test-install.yml:
--------------------------------------------------------------------------------
1 | name: Test Install Script
2 | run-name: "Install Test: ${{ github.event_name == 'pull_request' && format('PR #{0}', github.event.pull_request.number) || github.ref_name }} | ${{ github.event.head_commit.message || github.event.pull_request.title || 'Manual trigger' }} by ${{ github.actor }}"
3 |
4 | on:
5 | push:
6 | branches: [ master ]
7 | pull_request:
8 | branches: [ master ]
9 | paths:
10 | - 'install.sh'
11 | - '.github/workflows/test-install.yml'
12 |
13 | jobs:
14 | test-install-script:
15 | name: Test on ${{ matrix.os }}
16 | runs-on: ${{ matrix.os }}
17 | env:
18 | # Remote installer from the current repository and commit
19 | RAW_URL: ${{ format('https://raw.githubusercontent.com/{0}/{1}/install.sh', github.repository, github.sha) }}
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-13]
24 |
25 | steps:
26 | - name: Test install script help
27 | run: curl -fsSL "$RAW_URL" | sh -s -- --help
28 | - name: Test with bash (if available)
29 | run: curl -fsSL "$RAW_URL" | bash -s -- --help
30 |
31 | - name: Test install latest
32 | run: curl -fsSL "$RAW_URL" | sh
33 | - name: Check DDNS
34 | run: ddns --version
35 | - name: Test uninstall
36 | run: curl -fsSL "$RAW_URL" | sh -s -- --uninstall
37 |
38 |
39 | - name: Test install beta with language detection (zh_CN)
40 | run: curl -fsSL "$RAW_URL" | LANG=zh_CN.UTF-8 sh -s -- beta
41 | - name: Test install help with language detection (zh_CN)
42 | run: curl -fsSL "$RAW_URL" | LANG=zh_CN.UTF-8 sh -s -- --help
43 | - name: Check DDNS
44 | run: ddns --version
45 | - name: Test uninstall
46 | run: curl -fsSL "$RAW_URL" | sh -s -- --uninstall
47 |
48 | - name: Test install location by wget
49 | run: wget -qO - "$RAW_URL" | sh -s -- --install-dir /tmp/ddns
50 | - name: Check DDNS in custom location
51 | run: /tmp/ddns/ddns --version
52 |
53 | test-with-container:
54 | name: Test on Container (${{ matrix.container }})
55 | runs-on: ubuntu-latest
56 | strategy:
57 | matrix:
58 | container:
59 | - alpine:latest
60 | - debian:latest
61 | - openwrt/rootfs:latest
62 |
63 | container: ${{ matrix.container }}
64 | env:
65 | # Remote installer from the current repository and commit
66 | RAW_URL: ${{ format('https://raw.githubusercontent.com/{0}/{1}/install.sh', github.repository, github.sha) }}
67 |
68 | steps:
69 | - name: install wget
70 | if: matrix.container == 'debian:latest'
71 | run: apt-get update && apt-get install -y wget
72 | - name: Download install script
73 | run: wget "$RAW_URL" && chmod +x install.sh
74 |
75 | - name: Test install script help
76 | run: ./install.sh --help
77 |
78 | - name: Test install latest
79 | run: ./install.sh
80 | - name: Check DDNS
81 | run: ddns --version
82 | - name: Test uninstall
83 | run: ./install.sh --uninstall
84 |
85 | - name: Test install beta with language detection (zh_CN)
86 | run: LANG=zh_CN.UTF-8 ./install.sh beta
87 | - name: Check DDNS
88 | run: ddns --version
89 | - name: Test uninstall
90 | run: ./install.sh --uninstall
91 |
--------------------------------------------------------------------------------
/ddns/util/fileio.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | File I/O utilities for DDNS with Python 2/3 compatibility
4 | @author: NewFuture
5 | """
6 |
7 | import os
8 | from io import open # Python 2/3 compatible UTF-8 file operations
9 |
10 |
11 | def _ensure_directory_exists(file_path): # type: (str) -> None
12 | """
13 | Internal helper to ensure directory exists for the given file path
14 |
15 | Args:
16 | file_path (str): File path whose directory should be created
17 |
18 | Raises:
19 | OSError: If directory cannot be created
20 | """
21 | directory = os.path.dirname(file_path)
22 | if directory and not os.path.exists(directory):
23 | os.makedirs(directory)
24 |
25 |
26 | def read_file_safely(file_path, encoding="utf-8", default=None): # type: (str, str, str|None) -> str
27 | """
28 | Safely read file content with UTF-8 encoding, return None if file doesn't exist or can't be read
29 |
30 | Args:
31 | file_path (str): Path to the file to read
32 | encoding (str): File encoding (default: utf-8)
33 |
34 | Returns:
35 | str | None: File content or None if failed
36 | """
37 | try:
38 | return read_file(file_path, encoding)
39 | except Exception:
40 | return default # type: ignore
41 |
42 |
43 | def write_file_safely(file_path, content, encoding="utf-8"): # type: (str, str, str) -> bool
44 | """
45 | Safely write content to file with UTF-8 encoding
46 |
47 | Args:
48 | file_path (str): Path to the file to write
49 | content (str): Content to write
50 | encoding (str): File encoding (default: utf-8)
51 |
52 | Returns:
53 | bool: True if write successful, False otherwise
54 | """
55 | try:
56 | write_file(file_path, content, encoding)
57 | return True
58 | except Exception:
59 | return False
60 |
61 |
62 | def read_file(file_path, encoding="utf-8"): # type: (str, str) -> str
63 | """
64 | Read file content with UTF-8 encoding, raise exception if failed
65 |
66 | Args:
67 | file_path (str): Path to the file to read
68 | encoding (str): File encoding (default: utf-8)
69 |
70 | Returns:
71 | str: File content
72 |
73 | Raises:
74 | IOError: If file cannot be read
75 | UnicodeDecodeError: If file cannot be decoded with specified encoding
76 | """
77 | with open(file_path, "r", encoding=encoding) as f:
78 | return f.read()
79 |
80 |
81 | def write_file(file_path, content, encoding="utf-8"): # type: (str, str, str) -> None
82 | """
83 | Write content to file with UTF-8 encoding, raise exception if failed
84 |
85 | Args:
86 | file_path (str): Path to the file to write
87 | content (str): Content to write
88 | encoding (str): File encoding (default: utf-8)
89 |
90 | Raises:
91 | IOError: If file cannot be written
92 | UnicodeEncodeError: If content cannot be encoded with specified encoding
93 | """
94 | _ensure_directory_exists(file_path)
95 | with open(file_path, "w", encoding=encoding) as f:
96 | f.write(content)
97 |
98 |
99 | def ensure_directory(file_path): # type: (str) -> bool
100 | """
101 | Ensure the directory for the given file path exists
102 |
103 | Args:
104 | file_path (str): File path whose directory should be created
105 |
106 | Returns:
107 | bool: True if directory exists or was created successfully
108 | """
109 | try:
110 | _ensure_directory_exists(file_path)
111 | return True
112 | except (OSError, IOError):
113 | return False
114 |
--------------------------------------------------------------------------------
/.github/scripts/update_agents_structure.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | """Compare AGENTS.md directory structure with actual files."""
4 |
5 | import os
6 | import re
7 | import sys
8 |
9 | REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10 | ISSUE_BODY_FILE = os.path.join(REPO_ROOT, ".github", "issue_body.md")
11 |
12 |
13 | def scan_files(directory, extensions):
14 | # type: (str, tuple) -> set
15 | """Scan directory for files with given extensions."""
16 | result = set()
17 | base = os.path.join(REPO_ROOT, directory)
18 | if not os.path.isdir(base):
19 | return result
20 | for root, dirs, files in os.walk(base):
21 | dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
22 | for f in files:
23 | if not f.startswith(".") and f.endswith(extensions):
24 | result.add(os.path.relpath(os.path.join(root, f), REPO_ROOT).replace(os.sep, "/"))
25 | return result
26 |
27 |
28 | def parse_agents_md():
29 | # type: () -> set
30 | """Extract file paths from AGENTS.md directory structure (Tab-indented)."""
31 | agents_file = os.path.join(REPO_ROOT, "AGENTS.md")
32 | if not os.path.exists(agents_file):
33 | print("Error: AGENTS.md not found")
34 | sys.exit(1)
35 |
36 | with open(agents_file, "r", encoding="utf-8") as f:
37 | content = f.read()
38 |
39 | match = re.search(r"### Directory Structure.*?```text\s*\n(.*?)```\s*", content, re.DOTALL)
40 | if not match:
41 | return set()
42 |
43 | files = set()
44 | stack = [] # type: list
45 | for line in match.group(1).split("\n"):
46 | if not line.strip():
47 | continue
48 | depth = len(line) - len(line.lstrip("\t"))
49 | name = line.lstrip("\t").split(":")[0].strip()
50 | if not name or "*" in name or name == "...":
51 | continue
52 |
53 | stack = stack[:depth]
54 | if name.endswith("/"):
55 | stack.append(name.rstrip("/"))
56 | else:
57 | path = "/".join(stack + [name])
58 | if path.startswith(("ddns/", "doc/", "schema/")) and not path.endswith((".png", ".svg", ".jpg", ".gif", ".ico")):
59 | files.add(path)
60 | return files
61 |
62 |
63 | def main():
64 | # type: () -> None
65 | actual = (
66 | scan_files("ddns", (".py")) | scan_files("doc", (".md",)) | scan_files("schema", (".json",))
67 | )
68 | documented = parse_agents_md()
69 |
70 | added, deleted = sorted(actual - documented), sorted(documented - actual)
71 |
72 | # Remove old issue body file if exists
73 | if os.path.exists(ISSUE_BODY_FILE):
74 | os.remove(ISSUE_BODY_FILE)
75 |
76 | if not added and not deleted:
77 | print("No changes detected")
78 | sys.exit(0)
79 |
80 | # Build and write issue body
81 | lines = ["AGENTS.md directory structure is out of sync.\n"]
82 | if added:
83 | lines.append("## New Files\n")
84 | lines.extend("- `%s`" % f for f in added)
85 | lines.append("")
86 | if deleted:
87 | lines.append("## Missing Files\n")
88 | lines.extend("- `%s`" % f for f in deleted)
89 | lines.append("")
90 | lines.append("## Required Updates\n1. Update directory structure\n2. Update version/date")
91 | lines.append("\n---\n*Auto-generated by update-agents workflow.*")
92 |
93 | with open(ISSUE_BODY_FILE, "w", encoding="utf-8") as f:
94 | f.write("\n".join(lines))
95 |
96 | print("Changes detected: %d new, %d missing" % (len(added), len(deleted)))
97 |
98 |
99 | if __name__ == "__main__":
100 | main()
101 |
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | # DDNS 测试指南 / DDNS Testing Guide
2 |
3 | 本文档说明如何运行DDNS项目的测试。**unittest** 是默认的测试框架,因为它是Python内置的,无需额外依赖。
4 |
5 | This document explains how to run tests for the DDNS project. **unittest** is the default testing framework as it's built into Python and requires no additional dependencies.
6 |
7 | ## 快速开始 / Quick Start
8 |
9 | ### 默认方法 unittest / Default Method (unittest)
10 |
11 | ```bash
12 | # 运行所有测试(推荐)/ Run all tests (recommended)
13 | python -m unittest discover tests -v
14 |
15 | # 运行基础测试文件 / Run base test file
16 | python tests/base_test.py -v
17 |
18 | # 运行特定测试文件 / Run specific test file
19 | python -m unittest tests.test_provider_he -v
20 | python -m unittest tests.test_provider_dnspod -v
21 |
22 | # 运行特定测试类 / Run specific test class
23 | python -m unittest tests.test_provider_he.TestHeProvider -v
24 |
25 | # 运行特定测试方法 / Run specific test method
26 | python -m unittest tests.test_provider_he.TestHeProvider.test_init_with_basic_config -v
27 | ```
28 |
29 | ### 可选:使用 pytest / Optional: Using pytest (Advanced Users)
30 |
31 | 如果你偏好pytest的特性,需要先安装:
32 |
33 | If you prefer pytest features, install it first:
34 |
35 | ```bash
36 | # 或者直接安装 / or directly:
37 | pip install pytest
38 |
39 | # 运行所有测试 / Run all tests
40 | pytest tests/ -v
41 |
42 | # 运行特定测试文件 / Run specific test file
43 | pytest tests/test_provider_he.py -v
44 |
45 | ```
46 |
47 | ## 测试结构 / Test Structure
48 |
49 | ```
50 | tests/
51 | ├── __init__.py # 测试包初始化 / Makes tests a package
52 | ├── base_test.py # 共享测试工具和基类 / Shared test utilities and base classes
53 | ├── test_provider_*.py # 各个提供商的测试文件 / Tests for each provider
54 | └── README.md # 本测试指南 / This testing guide
55 | ```
56 |
57 | ## 测试配置 / Test Configuration
58 |
59 | 项目同时支持unittest(默认)和pytest测试框架:
60 |
61 | The project supports both unittest (default) and pytest testing frameworks:
62 |
63 | ## 编写测试 / Writing Tests
64 |
65 | ### 使用基础测试类 / Using the Base Test Class
66 |
67 | 所有提供商测试都应该继承`BaseProviderTestCase`:
68 |
69 | All provider tests should inherit from `BaseProviderTestCase`:
70 |
71 | ```python
72 | from base_test import BaseProviderTestCase, unittest, patch, MagicMock
73 | from ddns.provider.your_provider import YourProvider
74 |
75 | class TestYourProvider(BaseProviderTestCase):
76 | def setUp(self):
77 | super(TestYourProvider, self).setUp()
78 | # 提供商特定的设置 / Provider-specific setup
79 |
80 | def test_your_feature(self):
81 | provider = YourProvider(self.id, self.token)
82 | # 测试实现 / Test implementation
83 | ```
84 |
85 | ### 测试命名约定 / Test Naming Convention
86 |
87 | - 测试文件 / Test files: `test_provider_*.py`
88 | - 测试类 / Test classes: `Test*Provider`
89 | - 测试方法 / Test methods: `test_*`
90 |
91 | ## Python版本兼容性 / Python Version Compatibility
92 |
93 | 测试设计为同时兼容Python 2.7和Python 3.x:
94 |
95 | Tests are designed to work with both Python 2.7 and Python 3.x:
96 |
97 | - `mock` vs `unittest.mock`的导入处理 / Import handling for `mock` vs `unittest.mock`
98 | - 字符串类型兼容性 / String type compatibility
99 | - 异常处理兼容性 / Exception handling compatibility
100 | - print语句/函数兼容性 / Print statement/function compatibility
101 |
102 | ### 常见问题 / Common Issues
103 |
104 | 1. **导入错误 / Import errors**: 确保从项目根目录运行测试 / Ensure you're running tests from the project root directory
105 | 2. **找不到Mock / Mock not found**: 为Python 2.7安装`mock`包:`pip install mock` / Install `mock` package for Python 2.7: `pip install mock==3.0.5`
106 | 3. **找不到pytest / pytest not found**: 安装pytest:`pip install pytest` / Install pytest: `pip install pytest`
107 |
108 | **注意**: 项目已通过修改 `tests/__init__.py` 解决了模块导入路径问题,现在所有运行方式都能正常工作。
109 |
110 | **Note**: The project has resolved module import path issues by modifying `tests/__init__.py`, and now all running methods work correctly.
111 |
--------------------------------------------------------------------------------
/doc/install.en.md:
--------------------------------------------------------------------------------
1 | # One-Click Installation Script
2 |
3 | DDNS one-click installation script with support for automatic download and installation on Linux and macOS systems.
4 |
5 | ## Quick Installation
6 |
7 | ```bash
8 | # Install latest stable version online
9 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh
10 |
11 | # Use sudo if root permission is needed for system directory
12 | curl -#fSL https://ddns.newfuture.cc/install.sh | sudo sh
13 |
14 | # Or using wget
15 | wget -qO- https://ddns.newfuture.cc/install.sh | sh
16 | ```
17 |
18 | > **Note:** Default installation to `/usr/local/bin`. If the directory requires administrator privileges, the script will automatically prompt to use sudo, or you can run with sudo in advance.
19 |
20 | ## Version Selection
21 |
22 | ```bash
23 | # Install latest stable version
24 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- latest
25 |
26 | # Install latest beta version
27 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- beta
28 |
29 | # Install specific version
30 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- v4.0.2
31 | ```
32 |
33 | ## Command Line Options
34 |
35 | | Option | Description |
36 | |--------|-------------|
37 | | `latest` | Install latest stable version (default) |
38 | | `beta` | Install latest beta version |
39 | | `v4.0.2` | Install specific version |
40 | | `--install-dir PATH` | Specify installation directory (default: /usr/local/bin) |
41 | | `--proxy URL` | Specify proxy domain/prefix (e.g., `https://hub.gitmirror.com/`), overrides auto-detection |
42 | | `--force` | Force reinstallation |
43 | | `--uninstall` | Uninstall installed ddns |
44 | | `--help` | Show help information |
45 |
46 | ## Advanced Usage
47 |
48 | ```bash
49 | # Custom installation directory
50 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- beta --install-dir ~/.local/bin
51 |
52 | # Force reinstallation
53 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- --force
54 |
55 | # Uninstall
56 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- --uninstall
57 |
58 | # Specify proxy domain (override auto-detection)
59 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- --proxy https://hub.gitmirror.com/
60 | ```
61 |
62 | ## System Support
63 |
64 | **Operating Systems:** Linux (glibc/musl), macOS
65 | **Architectures:** x86_64, ARM64, ARM v7, ARM v6, i386
66 | **Dependencies:** curl or wget
67 |
68 | ### Auto-Detection Features
69 | - **System Detection:** Automatically identifies operating system, architecture and libc type
70 | - **Tool Detection:** Automatically selects curl or wget download tool
71 | - **Network Optimization:** Automatically tests and selects the best download mirror (github.com → China mirror sites)
72 | - **Manual Override:** Use `--proxy` to specify a proxy domain/mirror prefix, which takes precedence over auto-detection
73 |
74 | ## Verify Installation
75 |
76 | ```bash
77 | ddns --version # Check version
78 | which ddns # Check installation location
79 | ```
80 |
81 | ## Update & Uninstall
82 |
83 | ```bash
84 | # Update to latest version
85 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- latest
86 |
87 | # Uninstall
88 | curl -#fSL https://ddns.newfuture.cc/install.sh | sh -s -- --uninstall
89 |
90 | # Manual uninstall
91 | sudo rm -f /usr/local/bin/ddns
92 | ```
93 |
94 | ## Troubleshooting
95 |
96 | **Permission Issues:** Use `sudo` or install to user directory
97 | **Network Issues:** Script automatically uses mirror sites (hub.gitmirror.com, proxy.gitwarp.com, etc.)
98 | **Unsupported Architecture:** Check [releases page](https://github.com/NewFuture/DDNS/releases) for supported architectures
99 | **Proxy Environment:** The script respects system proxy settings (`HTTP_PROXY/HTTPS_PROXY`); you can also use `--proxy https://hub.gitmirror.com/` to specify a GitHub mirror prefix (overrides auto-detection)
100 |
--------------------------------------------------------------------------------
/doc/providers/debug.en.md:
--------------------------------------------------------------------------------
1 | # Debug Provider Configuration Guide
2 |
3 | ## Overview
4 |
5 | Debug Provider is a virtual DNS provider specifically designed for debugging and testing purposes. It simulates the DNS record update process but does not perform any actual operations, only outputs relevant information to the console to help developers debug DDNS configuration and functionality.
6 |
7 | Official Links:
8 |
9 | - Project Homepage: [DDNS Project](https://github.com/NewFuture/DDNS)
10 | - Development Documentation: [Provider Development Guide](../dev/provider.en.md)
11 |
12 | ### Important Notice
13 |
14 | - Debug Provider **is only for debugging and testing**, it does not perform any actual DNS update operations
15 | - Only prints detected IP addresses and domain information to the console
16 | - Suitable for verifying configuration file format and IP address detection functionality
17 |
18 | ## Authentication Information
19 |
20 | Debug Provider does not require any authentication information, no need to configure `id` and `token` parameters.
21 |
22 | ```json
23 | {
24 | "dns": "debug" // Only need to specify provider as debug
25 | }
26 | ```
27 |
28 | ## Complete Configuration Example
29 |
30 | ```json
31 | {
32 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // Format validation
33 | "dns": "debug", // Current provider
34 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4 address source
35 | "index6": "public", // IPv6 address source
36 | "ipv4": ["ddns.newfuture.cc"], // IPv4 domains
37 | "ipv6": ["ipv6.ddns.newfuture.cc"], // IPv6 domains
38 | "cache": false, // Recommend disabling cache for debugging
39 | "log": {
40 | "level": "debug" // Log level
41 | }
42 | }
43 | ```
44 |
45 | ### Parameter Description
46 |
47 | | Parameter | Description | Type | Range/Options | Default | Parameter Type |
48 | | :-------: | :---------- | :--- | :------------ | :------ | :------------- |
49 | | dns | Provider identifier | String | `debug` | None | Provider Parameter |
50 | | index4 | IPv4 source | Array | [Reference](../config/json.en.md#ipv4-ipv6) | `default` | Common Config |
51 | | index6 | IPv6 source | Array | [Reference](../config/json.en.md#ipv4-ipv6) | `default` | Common Config |
52 | | proxy | Proxy settings | Array | [Reference](../config/json.en.md#proxy) | None | Common Network |
53 | | ssl | SSL verification | Boolean/String | `"auto"`, `true`, `false` | `auto` | Common Network |
54 | | cache | Cache settings | Boolean/String | `true`, `false`, `filepath` | `false` | Common Config |
55 | | log | Log configuration | Object | [Reference](../config/json.en.md#log) | None | Common Config |
56 |
57 | > **Parameter Type Description**:
58 | >
59 | > - **Common Config**: Standard DNS configuration parameters applicable to all supported DNS providers
60 | > - **Common Network**: Network setting parameters applicable to all supported DNS providers
61 |
62 | ## Command Line Usage
63 |
64 | ```sh
65 | ddns --debug
66 | ```
67 |
68 | ### Specify Parameters
69 |
70 | ```sh
71 | ddns --dns debug --index4=0 --ipv4=ddns.newfuture.cc --debug
72 | ```
73 |
74 | ### Output Log
75 |
76 | ```log
77 | INFO DebugProvider: ddns.newfuture.cc(A) => 192.168.1.100
78 | ```
79 |
80 | ### Error Simulation
81 |
82 | Debug Provider also simulates some common error scenarios to help test error handling logic.
83 |
84 | ## Troubleshooting
85 |
86 | ### Common Issues
87 |
88 | - **No output information**: Check log level settings, ensure DEBUG level is enabled
89 | - **IP detection failed**: Check network connection and IP source configuration
90 | - **Configuration format error**: Use JSON validation tools to check configuration file format
91 |
92 | ## Support and Resources
93 |
94 | - [DDNS Project Documentation](../../README.md)
95 | - [Configuration File Format](../config/json.en.md)
96 | - [Command Line Usage Guide](../config/cli.en.md)
97 | - [Developer Guide](../dev/provider.en.md)
98 |
--------------------------------------------------------------------------------
/doc/providers/he.md:
--------------------------------------------------------------------------------
1 | # HE.net (Hurricane Electric) 配置指南
2 |
3 | ## 概述
4 |
5 | Hurricane Electric (HE.net) 是知名的网络服务商,提供免费的 DNS 托管服务,支持动态 DNS 记录更新。本 DDNS 项目通过 HE.net 的动态 DNS 密码进行认证。
6 |
7 | > ⚠️ **注意**:HE.net Provider 目前处于**待验证**状态,缺少充分的真实环境测试。请通过 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 反馈。
8 |
9 | **重要限制**:HE.net **不支持自动创建记录**,必须先在 HE.net 控制面板中手动创建 DNS 记录。
10 |
11 | 官网链接:
12 |
13 | - 官方网站:
14 | - 服务商控制台:
15 |
16 | ## 认证信息
17 |
18 | ### 动态 DNS 密码认证
19 |
20 | HE.net 使用专门的动态 DNS 密码进行认证,不使用账户登录密码。
21 |
22 | 需要提前创建DNS记录和开启DNS
23 |
24 | 1. 在 [HE.net DNS 管理面板](https://dns.he.net)中选择要管理的域名
25 | 2. **创建DNS记录**:手动创建 A (ipv4)或 AAAA (ipv6)记录
26 | 3. **启用DDNS**:为记录启用动态 DNS 功能
27 | 4. **获取密码**:点击旁边的 `Generate a DDNS key` 或 `Enable entry for DDNS`
28 |
29 | ```json
30 | {
31 | "dns": "he",
32 | "token": "your_ddns_key" // HE.net 动态 DNS 密码,不需要ID
33 | }
34 | ```
35 |
36 | ## 完整配置示例
37 |
38 | ```json
39 | {
40 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
41 | "dns": "he", // 当前服务商
42 | "token": "your_ddns_key", // HE.net 动态 DNS 密码
43 | "index4": ["public", 0], // IPv4地址来源, 与A记录值对应
44 | "ipv4": "ddns.newfuture.cc" // IPv4 域名, 与A记录对应
45 | }
46 | ```
47 |
48 | ### 参数说明
49 |
50 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
51 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
52 | | dns | 服务商标识 | 字符串 | `he` | 无 | 服务商参数 |
53 | | token | 认证密钥 | 字符串 | HE.net DDNS 密码 | 无 | 服务商参数 |
54 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
55 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
56 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
57 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
58 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
59 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
60 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
61 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
62 |
63 | > **参数类型说明**:
64 | >
65 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
66 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数
67 | > - **服务商参数**:当前服务商支持,值与当前服务商相关
68 | >
69 | > **注意**:HE.net 不支持 `id` 参数,仅使用 `token` (DDNS Key)进行认证; ttl固定为300s。
70 |
71 | ## 使用限制
72 |
73 | - ❌ **不支持自动创建记录**:必须先在 HE.net 控制面板中手动创建 DNS 记录
74 | - ⚠️ **仅支持更新**:只能更新现有记录的 IP 地址,不能创建新记录
75 | - 🔑 **专用密码**:每个记录都有独立的 DDNS 密码
76 |
77 | ## 故障排除
78 |
79 | ### 调试模式
80 |
81 | 启用调试日志查看详细信息:
82 |
83 | ```sh
84 | ddns -c config.json --debug
85 | ```
86 |
87 | ### 常见问题
88 |
89 | - **认证失败**:检查动态 DNS 密码是否正确,确认记录已启用 DDNS 功能
90 | - **域名未找到**:确保记录已在 HE.net 控制面板中手动创建,域名拼写无误
91 | - **记录更新失败**:检查记录是否已启用动态 DNS,确认密码对应正确的记录
92 | - **请求频率限制**:HE.net 建议更新间隔不少于 5 分钟,避免频繁更新
93 |
94 | ### HE.net 响应代码
95 |
96 | | 响应代码 | 说明 | 解决方案 |
97 | | :------------- | :--------------- | :----------------- |
98 | | `good ` | 更新成功 | 操作成功 |
99 | | `nochg ` | IP地址无变化 | 操作成功 |
100 | | `nohost` | 主机名不存在 | 检查记录和DDNS设置 |
101 | | `badauth` | 认证失败 | 检查动态DNS密码 |
102 | | `badagent` | 客户端被禁用 | 联系HE.net支持 |
103 | | `abuse` | 更新过于频繁 | 增加更新间隔 |
104 |
105 | ## 支持与资源
106 |
107 | - [HE.net 官网](https://he.net/)
108 | - [HE.net DNS 管理](https://dns.he.net/)
109 | - [HE.net DDNS 文档](https://dns.he.net/docs.html)
110 | - [HE.net 技术支持](https://he.net/contact.html)
111 |
112 | > ⚠️ **待验证状态**:HE.net Provider 缺少充分的真实环境测试,建议在生产环境使用前进行充分测试。如遇问题请通过 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 反馈。
113 |
--------------------------------------------------------------------------------
/ddns/provider/dnscom.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | DNSCOM/51dns API 接口解析操作库
4 | www.51dns.com (原dns.com)
5 | @author: Bigjin, NewFuture
6 | """
7 |
8 | from hashlib import md5
9 | from time import time
10 |
11 | from ._base import TYPE_FORM, BaseProvider, encode_params
12 |
13 |
14 | class DnscomProvider(BaseProvider):
15 | """
16 | DNSCOM/51dns API Provider
17 | https://www.51dns.com/document/api/index.html
18 | """
19 |
20 | endpoint = "https://www.51dns.com"
21 | content_type = TYPE_FORM
22 |
23 | def _validate(self):
24 | self.logger.warning(
25 | "DNS.COM 缺少充分的真实环境测试,请及时在 GitHub Issues 中反馈: %s",
26 | "https://github.com/NewFuture/DDNS/issues",
27 | )
28 | super(DnscomProvider, self)._validate()
29 |
30 | def _signature(self, params):
31 | """https://www.51dns.com/document/api/70/72.html"""
32 | params = {k: v for k, v in params.items() if v is not None}
33 | params.update(
34 | {
35 | "apiKey": self.id,
36 | "timestamp": time(), # 时间戳
37 | }
38 | )
39 | query = encode_params(params)
40 | sign = md5((query + self.token).encode("utf-8")).hexdigest()
41 | params["hash"] = sign
42 | return params
43 |
44 | def _request(self, action, **params):
45 | params = self._signature(params)
46 | data = self._http("POST", "/api/{}/".format(action), body=params)
47 | if data is None or not isinstance(data, dict):
48 | raise Exception("response data is none")
49 | if data.get("code", 0) != 0:
50 | raise Exception("api error: " + str(data.get("message")))
51 | return data.get("data")
52 |
53 | def _query_zone_id(self, domain):
54 | """https://www.51dns.com/document/api/74/31.html"""
55 | res = self._request("domain/getsingle", domainID=domain)
56 | self.logger.debug("Queried domain: %s", res)
57 | if res:
58 | return res.get("domainID")
59 | return None
60 |
61 | def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
62 | """https://www.51dns.com/document/api/4/47.html"""
63 | records = self._request("record/list", domainID=zone_id, host=subdomain, pageSize=500)
64 | records = records.get("data", []) if records else []
65 | for record in records:
66 | if (
67 | record.get("record") == subdomain
68 | and record.get("type") == record_type
69 | and (line is None or record.get("viewID") == line)
70 | ):
71 | return record
72 | return None
73 |
74 | def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
75 | """https://www.51dns.com/document/api/4/12.html"""
76 | extra["remark"] = extra.get("remark", self.remark)
77 | res = self._request(
78 | "record/create",
79 | domainID=zone_id,
80 | value=value,
81 | host=subdomain,
82 | type=record_type,
83 | TTL=ttl,
84 | viewID=line,
85 | **extra
86 | ) # fmt: skip
87 | if res and res.get("recordID"):
88 | self.logger.info("Record created: %s", res)
89 | return True
90 | self.logger.error("Failed to create record: %s", res)
91 | return False
92 |
93 | def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
94 | """https://www.51dns.com/document/api/4/45.html"""
95 | extra["remark"] = extra.get("remark", self.remark)
96 | res = self._request(
97 | "record/modify", domainID=zone_id, recordID=old_record.get("recordID"), newvalue=value, newTTL=ttl
98 | )
99 | if res:
100 | self.logger.info("Record updated: %s", res)
101 | return True
102 | self.logger.error("Failed to update record: %s", res)
103 | return False
104 |
--------------------------------------------------------------------------------
/ddns/provider/_signature.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | DNS Provider 签名和哈希算法模块
4 |
5 | Signature and hash algorithms module for DNS providers.
6 | Provides common cryptographic functions for cloud provider authentication.
7 |
8 | @author: NewFuture
9 | """
10 |
11 | from hashlib import sha256
12 | from hmac import HMAC
13 |
14 |
15 | def hmac_sha256(key, message):
16 | # type: (str | bytes, str | bytes) -> HMAC
17 | """
18 | 计算 HMAC-SHA256 签名对象
19 |
20 | Compute HMAC-SHA256 signature object.
21 |
22 | Args:
23 | key (str | bytes): 签名密钥 / Signing key
24 | message (str | bytes): 待签名消息 / Message to sign
25 |
26 | Returns:
27 | HMAC: HMAC签名对象,可调用.digest()获取字节或.hexdigest()获取十六进制字符串
28 | HMAC signature object, call .digest() for bytes or .hexdigest() for hex string
29 | """
30 | # Python 2/3 compatible encoding - avoid double encoding in Python 2
31 | if not isinstance(key, bytes):
32 | key = key.encode("utf-8")
33 | if not isinstance(message, bytes):
34 | message = message.encode("utf-8")
35 | return HMAC(key, message, sha256)
36 |
37 |
38 | def sha256_hash(data):
39 | # type: (str | bytes) -> str
40 | """
41 | 计算 SHA256 哈希值
42 |
43 | Compute SHA256 hash.
44 |
45 | Args:
46 | data (str | bytes): 待哈希数据 / Data to hash
47 |
48 | Returns:
49 | str: 十六进制哈希字符串 / Hexadecimal hash string
50 | """
51 | # Python 2/3 compatible encoding - avoid double encoding in Python 2
52 | if not isinstance(data, bytes):
53 | data = data.encode("utf-8")
54 | return sha256(data).hexdigest()
55 |
56 |
57 | def hmac_sha256_authorization(
58 | secret_key, # type: str | bytes
59 | method, # type: str
60 | path, # type: str
61 | query, # type: str
62 | headers, # type: dict[str, str]
63 | body_hash, # type: str
64 | signing_string_format, # type: str
65 | authorization_format, # type: str
66 | ):
67 | # type: (...) -> str
68 | """
69 | HMAC-SHA256 云服务商通用认证签名生成器
70 |
71 | Universal cloud provider authentication signature generator using HMAC-SHA256.
72 |
73 | 通用的云服务商API认证签名生成函数,使用HMAC-SHA256算法生成符合各云服务商规范的Authorization头部。
74 |
75 | 模板变量格式:{HashedCanonicalRequest}, {SignedHeaders}, {Signature}
76 |
77 | Args:
78 | secret_key (str | bytes): 签名密钥,已经过密钥派生处理 / Signing key (already derived if needed)
79 | method (str): HTTP请求方法 / HTTP request method (GET, POST, etc.)
80 | path (str): API请求路径 / API request path
81 | query (str): URL查询字符串 / URL query string
82 | headers (dict[str, str]): HTTP请求头部 / HTTP request headers
83 | body_hash (str): 请求体的SHA256哈希值 / SHA256 hash of request payload
84 | signing_string_format (str): 待签名字符串模板,包含 {HashedCanonicalRequest} 占位符
85 | authorization_format (str): Authorization头部模板,包含 {SignedHeaders}, {Signature} 占位符
86 |
87 | Returns:
88 | str: 完整的Authorization头部值 / Complete Authorization header value
89 | """
90 | # 1. 构建规范化头部 - 所有传入的头部都参与签名
91 | headers_to_sign = {k.lower(): str(v).strip() for k, v in headers.items()}
92 | signed_headers_list = sorted(headers_to_sign.keys())
93 |
94 | # 2. 构建规范请求字符串
95 | canonical_headers = ""
96 | for header_name in signed_headers_list:
97 | canonical_headers += "{}:{}\n".format(header_name, headers_to_sign[header_name])
98 |
99 | # 构建完整的规范请求字符串
100 | signed_headers = ";".join(signed_headers_list)
101 | canonical_request = "\n".join([method.upper(), path, query, canonical_headers, signed_headers, body_hash])
102 |
103 | # 3. 构建待签名字符串 - 只需要替换 HashedCanonicalRequest
104 | hashed_canonical_request = sha256_hash(canonical_request)
105 | string_to_sign = signing_string_format.format(HashedCanonicalRequest=hashed_canonical_request)
106 |
107 | # 4. 计算最终签名
108 | signature = hmac_sha256(secret_key, string_to_sign).hexdigest()
109 |
110 | # 5. 构建Authorization头部 - 只需要替换 SignedHeaders 和 Signature
111 | authorization = authorization_format.format(SignedHeaders=signed_headers, Signature=signature)
112 |
113 | return authorization
114 |
--------------------------------------------------------------------------------
/doc/providers/namesilo.md:
--------------------------------------------------------------------------------
1 | # NameSilo DNS 配置指南
2 |
3 | ## 概述
4 |
5 | NameSilo 是美国知名的域名注册商和 DNS 服务提供商,提供可靠的域名管理和 DNS 解析服务,支持动态 DNS 记录的创建与更新。本 DDNS 项目通过 API Key 进行认证。
6 |
7 | > ⚠️ **注意**:NameSilo Provider 目前处于**待验证**状态,缺少充分的真实环境测试。请通过 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 反馈。
8 |
9 | 官网链接:
10 |
11 | - 官方网站:
12 | - 服务商控制台:
13 |
14 | ## 认证信息
15 |
16 | ### API Key 认证
17 |
18 | NameSilo 使用 API Key 进行身份验证,这是唯一的认证方式。
19 |
20 | #### 获取认证信息
21 |
22 | 1. 登录 [NameSilo 控制台](https://www.namesilo.com/account_home.php)
23 | 2. 进入「Account Options」→「API Manager」或访问
24 | 3. 生成新的 API Key
25 |
26 | > **注意**:API Key 具有账户的完整权限,请确保妥善保管,不要泄露给他人。
27 |
28 | ```json
29 | {
30 | "dns": "namesilo",
31 | "token": "your_api_key_here" // NameSilo API Key, ID 不需要
32 | }
33 | ```
34 |
35 | ## 完整配置示例
36 |
37 | ```json
38 | {
39 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
40 | "dns": "namesilo", // 当前服务商
41 | "token": "c40031261ee449dda629d2df14e9cb63", // NameSilo API Key
42 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
43 | "index6": "public", // IPv6地址来源
44 | "ipv4": ["ddns.newfuture.cc"], // IPv4 域名
45 | "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"], // IPv6 域名
46 | "ttl": 3600 // DNS记录TTL(秒)
47 | }
48 | ```
49 |
50 | ### 参数说明
51 |
52 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
53 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
54 | | dns | 服务商标识 | 字符串 | `namesilo` | 无 | 服务商参数 |
55 | | token | 认证密钥 | 字符串 | NameSilo API Key | 无 | 服务商参数 |
56 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
57 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
58 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
59 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
60 | | ttl | TTL 时间 | 整数(秒) | 300 ~ 2592000 | `7200` | 服务商参数 |
61 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
62 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
63 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
64 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
65 |
66 | > **参数类型说明**:
67 | >
68 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
69 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数
70 | > - **服务商参数**:当前服务商支持,值与当前服务商相关
71 | >
72 | > **注意**:NameSilo 不支持 `id` 参数,仅使用 `token` 进行认证。
73 | > **注意**:NameSilo 官方 API 端点为 `https://www.namesilo.com`,除非使用代理服务,否则不建议修改。
74 |
75 | ## 故障排除
76 |
77 | ### 调试模式
78 |
79 | 启用调试日志查看详细信息:
80 |
81 | ```sh
82 | ddns -c config.json --debug
83 | ```
84 |
85 | ### 常见问题
86 |
87 | - **认证失败**:检查 API Key 是否正确,确认 API Key 没有被禁用,验证账户状态是否正常
88 | - **域名未找到**:确保域名已添加到 NameSilo 账户,配置拼写无误,域名处于活跃状态
89 | - **记录创建失败**:检查子域名格式是否正确,TTL 值在允许范围内(300-2592000秒),验证是否存在冲突记录
90 | - **请求频率限制**:NameSilo 有 API 调用频率限制(建议每分钟不超过 60 次),降低请求频率
91 |
92 | ### API 响应代码
93 |
94 | | 响应代码 | 说明 | 解决方案 |
95 | | :------ | :----------- | :----------------- |
96 | | 300 | 成功 | 操作成功 |
97 | | 110 | 域名不存在 | 检查域名配置 |
98 | | 280 | 无效的域名格式 | 检查域名格式 |
99 | | 200 | 无效的API Key | 检查API密钥 |
100 |
101 | ## API 限制
102 |
103 | - **请求频率**:建议每分钟不超过 60 次请求
104 | - **域名数量**:根据账户类型不同而限制
105 | - **记录数量**:每个域名最多 100 条 DNS 记录
106 |
107 | ## 支持与资源
108 |
109 | - [NameSilo 官网](https://www.namesilo.com/)
110 | - [NameSilo API 文档](https://www.namesilo.com/api-reference)
111 | - [NameSilo 控制台](https://www.namesilo.com/account_home.php)
112 | - [NameSilo API Manager](https://www.namesilo.com/account/api-manager)
113 |
114 | > ⚠️ **待验证状态**:NameSilo Provider 缺少充分的真实环境测试,建议在生产环境使用前进行充分测试。如遇问题请通过 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 反馈。
115 |
--------------------------------------------------------------------------------
/doc/providers/noip.md:
--------------------------------------------------------------------------------
1 | # No-IP 配置指南
2 |
3 | ## 概述
4 |
5 | No-IP 是流行的动态 DNS 服务提供商,支持标准的 DDNS 动态更新协议,采用 Basic Auth 认证,支持动态 DNS 记录的创建与更新。本 DDNS 项目支持通过用户名密码或 DDNS KEY 进行认证。
6 |
7 | 官网链接:
8 |
9 | - 官方网站:
10 | - 服务商控制台:
11 |
12 | ## 认证信息
13 |
14 | ### 1. DDNS KEY + ID 认证(推荐)
15 |
16 | 使用 DDNS ID 和 DDNS KEY 进行认证,更加安全。
17 |
18 | #### 获取DDNS KEY
19 |
20 | 1. 登录 [No-IP 官网](https://www.noip.com/)
21 | 2. 进入 **Dynamic DNS** > **No-IP Hostnames**
22 | 3. 创建或编辑动态 DNS 主机名
23 | 4. 生成 DDNS KEY 用于 API 认证
24 |
25 | ```json
26 | {
27 | "dns": "noip",
28 | "id": "your_ddns_id", // DDNS ID
29 | "token": "your_ddns_key" // DDNS KEY
30 | }
31 | ```
32 |
33 | ### 2. 用户名密码认证
34 |
35 | 使用 No-IP 账户用户名和密码进行认证,这是最简单的认证方式。
36 |
37 | #### 账号密码
38 |
39 | 1. 注册或登录 [No-IP 官网](https://www.noip.com/)
40 | 2. 使用注册的用户名和密码
41 | 3. 在控制面板中创建主机名(hostname)
42 |
43 | ```json
44 | {
45 | "dns": "noip",
46 | "id": "your_username", // No-IP 用户名
47 | "token": "your_password" // No-IP 密码
48 | }
49 | ```
50 |
51 | ## 完整配置示例
52 |
53 | ```json
54 | {
55 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
56 | "dns": "noip", // 当前服务商
57 | "id": "myusername", // No-IP 用户名或 DDNS ID
58 | "token": "mypassword", // No-IP 密码或 DDNS KEY
59 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
60 | "index6": "public", // IPv6地址来源
61 | "ipv4": ["all.ddnskey.com"], // IPv4 域名
62 | "ipv6": ["all.ddnskey.com"], // IPv6 域名
63 | "endpoint": "https://dynupdate.no-ip.com" // API端点
64 | }
65 | ```
66 |
67 | ### 参数说明
68 |
69 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
70 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
71 | | dns | 服务商标识 | 字符串 | `noip` | 无 | 服务商参数 |
72 | | id | 认证 ID | 字符串 | No-IP 用户名或 DDNS ID | 无 | 服务商参数 |
73 | | token | 认证密钥 | 字符串 | No-IP 密码或 DDNS KEY | 无 | 服务商参数 |
74 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
75 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
76 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | `all.ddnskey.com` | 公用配置 |
77 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | `all.ddnskey.com` | 公用配置 |
78 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
79 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
80 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
81 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
82 |
83 | > **参数类型说明**:
84 | >
85 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
86 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数
87 | > - **服务商参数**:前服务商支持,值与当前服务商相关
88 |
89 | ## 故障排除
90 |
91 | ### 调试模式
92 |
93 | 启用调试日志查看详细信息:
94 |
95 | ```sh
96 | ddns -c config.json --debug
97 | ```
98 |
99 | ### 常见问题
100 |
101 | - **认证失败**:检查用户名和密码是否正确,确认账户没有被禁用
102 | - **主机名未找到**:确保主机名已在 No-IP 控制面板中创建,配置拼写无误
103 | - **更新失败**:检查主机名状态是否正常,确认账户权限足够
104 | - **请求频率限制**:No-IP 建议更新间隔不少于 5 分钟,避免频繁更新
105 |
106 | ### No-IP 响应代码
107 |
108 | | 响应代码 | 说明 | 解决方案 |
109 | | :------------- | :--------------- | :----------------- |
110 | | `good ` | 更新成功 | 操作成功 |
111 | | `nochg ` | IP地址无变化 | 操作成功 |
112 | | `nohost` | 主机名不存在 | 检查主机名设置 |
113 | | `badauth` | 认证失败 | 检查用户名密码 |
114 | | `badagent` | 客户端被禁用 | 联系No-IP支持 |
115 | | `!donator` | 需要付费账户功能 | 升级账户类型 |
116 | | `abuse` | 账户被封禁或滥用 | 联系No-IP支持 |
117 |
118 | ## API 限制
119 |
120 | - **更新频率**:建议间隔不少于 5 分钟
121 | - **免费账户**:30 天内需至少一次登录确认
122 | - **主机名数量**:免费账户限制 3 个主机名
123 |
124 | ## 支持与资源
125 |
126 | - [No-IP 官网](https://www.noip.com/)
127 | - [No-IP API 文档](https://www.noip.com/integrate/request)
128 | - [No-IP 控制面板](https://www.noip.com/members/)
129 | - [No-IP 技术支持](https://www.noip.com/support)
130 |
131 | > **建议**:推荐使用 DDNS KEY 认证方式以提高安全性,定期检查主机名状态确保服务正常运行。
132 |
--------------------------------------------------------------------------------
/schema/v2.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema",
3 | "$id": "https://ddns.newfuture.cc/schema/v2.json",
4 | "description": "DDNS 配置文件 https://github.com/NewFuture/DDNS",
5 | "type": "object",
6 | "properties": {
7 | "$schema": {
8 | "type": "string",
9 | "enum": [
10 | "https://ddns.newfuture.cc/schema/v2.json",
11 | "http://ddns.newfuture.cc/schema/v2.json",
12 | "./schema/v2.json"
13 | ],
14 | "default": "https://ddns.newfuture.cc/schema/v2.json"
15 | },
16 | "id": {
17 | "$id": "/properties/id",
18 | "type": "string",
19 | "title": "ID or Email",
20 | "description": "DNS服务API认证的ID或者邮箱",
21 | "default": ""
22 | },
23 | "token": {
24 | "$id": "/properties/token",
25 | "type": "string",
26 | "title": "API Token",
27 | "description": "DNS服务商的访问Token或者Key",
28 | "default": ""
29 | },
30 | "dns": {
31 | "$id": "/properties/dns",
32 | "type": "string",
33 | "title": "DNS Provider",
34 | "description": "dns服务商:阿里为alidns,DNS.COM为dnscom,DNSPOD国际版为dnspod_com,cloudflare",
35 | "default": "dnspod",
36 | "examples": ["dnspod", "alidns", "cloudflare"],
37 | "enum": ["dnspod", "alidns", "cloudflare", "dnspod_com", "dnscom"]
38 | },
39 | "ipv4": {
40 | "$id": "/properties/ipv4",
41 | "title": "IPv4 domain list",
42 | "description": "待更新的IPv4 域名列表",
43 | "type": "array",
44 | "uniqueItems": true,
45 | "items": {
46 | "$id": "/properties/ipv4/items",
47 | "title": "ipv4 domain for DDNS",
48 | "type": "string",
49 | "pattern": "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,18}$",
50 | "examples": ["newfuture.cc", "ipv4.example.newfuture.cc"]
51 | }
52 | },
53 | "ipv6": {
54 | "$id": "/properties/ipv6",
55 | "type": "array",
56 | "title": "IPv6 domain list",
57 | "description": "待更新的IPv6 域名列表",
58 | "uniqueItems": true,
59 | "items": {
60 | "$id": "/properties/ipv6/items",
61 | "title": "The ipv6 domain for DDNS",
62 | "type": "string",
63 | "pattern": "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}$",
64 | "examples": ["newfuture.cc", "ipv6.example.newfuture.cc"]
65 | }
66 | },
67 | "index4": {
68 | "$id": "/properties/index4",
69 | "type": ["string", "integer", "boolean"],
70 | "title": "IPv4 address Setting",
71 | "description": "本机 IPv4 获取方式\n`default`默认请求IP\n`public`公网\n数字表示网卡\n`url:http://myip.com`从URL的返回结果中提取\n`reg:xxx`正则提取IPconfig\n`cmd:xxx`或`shell:xx`命令行提取",
72 | "default": "default",
73 | "examples": [
74 | "default",
75 | "public",
76 | 0,
77 | 1,
78 | "regex:192\\\\.168\\\\..*",
79 | "url:https://ipinfo.io/ip",
80 | false
81 | ]
82 | },
83 | "index6": {
84 | "$id": "/properties/index6",
85 | "type": ["string", "integer", "boolean"],
86 | "title": "IPv6 address Setting",
87 | "description": "本机 IPv6 获取方式:`default`默认请求IP\n`public`公网\n数字表示网卡\n`url:http://myip.com`从URL的返回结果中提取\n`reg:xxx`正则提取IPconfig\n`cmd:xxx`或`shell:xx`命令行提取",
88 | "default": "default",
89 | "examples": [
90 | "default",
91 | "public",
92 | 0,
93 | 1,
94 | "regex:2404:f801:10:.*",
95 | "url:https://api6.ipify.org/",
96 | false
97 | ]
98 | },
99 | "proxy": {
100 | "$id": "/properties/proxy",
101 | "type": ["string", "null"],
102 | "title": "HTTP Proxy Setting",
103 | "description": "DIRECT表示直连,多个代理分号(;)分割,逐个尝试直到成功",
104 | "pattern": "^[a-zA-Z0-9\\-;_:\\.]*$",
105 | "examples": ["127.0.0.1:1080;DIRECT"],
106 | "default": "null"
107 | },
108 | "debug": {
109 | "$id": "/properties/debug",
110 | "type": "boolean",
111 | "title": "Enable Debug Mode",
112 | "description": "是否启用调试模式显示更多信息",
113 | "default": false,
114 | "examples": [false, true]
115 | },
116 | "cache": {
117 | "$id": "/properties/cache",
118 | "type": "boolean",
119 | "title": "Enable Cache",
120 | "description": "是否使用缓存",
121 | "default": true,
122 | "examples": [false, true]
123 | }
124 | },
125 | "required": ["id", "token"],
126 | "additionalProperties": false
127 | }
128 |
--------------------------------------------------------------------------------
/ddns/provider/noip.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | No-IP (noip.com) Dynamic DNS API
4 | @author: GitHub Copilot
5 | """
6 |
7 | from ._base import SimpleProvider, TYPE_FORM, quote
8 |
9 |
10 | class NoipProvider(SimpleProvider):
11 | """
12 | No-IP (www.noip.com) Dynamic DNS Provider
13 |
14 | No-IP is a popular dynamic DNS service that provides simple HTTP-based
15 | API for updating DNS records. This provider supports the standard
16 | No-IP update protocol.
17 | """
18 |
19 | endpoint = "https://dynupdate.no-ip.com"
20 | content_type = TYPE_FORM
21 | accept = None # No-IP returns plain text response
22 | decode_response = False # Response is plain text, not JSON
23 |
24 | def _validate(self):
25 | """
26 | Validate authentication credentials for No-IP and update endpoint with auth
27 | """
28 | # Check endpoint first
29 | if not self.endpoint or "://" not in self.endpoint:
30 | raise ValueError("API endpoint must be defined and contain protocol")
31 |
32 | if not self.id:
33 | raise ValueError("No-IP requires username as 'id'")
34 | if not self.token:
35 | raise ValueError("No-IP requires password as 'token'")
36 |
37 | # Update endpoint with URL-encoded auth credentials
38 | protocol, domain = self.endpoint.split("://", 1)
39 | self.endpoint = "{0}://{1}:{2}@{3}".format(
40 | protocol, quote(self.id, safe=""), quote(self.token, safe=""), domain
41 | )
42 |
43 | def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
44 | """
45 | Update DNS record using No-IP Dynamic Update API
46 |
47 | No-IP API Reference:
48 | - URL: https://dynupdate.no-ip.com/nic/update
49 | - Method: GET or POST
50 | - Authentication: HTTP Basic Auth (username:password)
51 | - Parameters:
52 | - hostname: The hostname to update
53 | - myip: The IP address to set (optional, uses client IP
54 | if not provided)
55 |
56 | Response codes:
57 | - good: Update successful
58 | - nochg: IP address is current, no update performed
59 | - nohost: Hostname supplied does not exist
60 | - badauth: Invalid username/password combination
61 | - badagent: Client disabled
62 | - !donator: An update request was sent including a feature that
63 | is not available
64 | - abuse: Username is blocked due to abuse
65 | """
66 | self.logger.info("%s => %s(%s)", domain, value, record_type)
67 |
68 | # Prepare request parameters
69 | params = {"hostname": domain, "myip": value}
70 |
71 | try:
72 | # Use GET request as it's the most common method for DDNS
73 | # Endpoint already includes auth credentials from _validate()
74 | response = self._http("GET", "/nic/update", queries=params)
75 |
76 | if response is not None:
77 | response_str = str(response).strip()
78 | self.logger.info("No-IP API response: %s", response_str)
79 |
80 | # Check for successful responses
81 | if response_str.startswith("good") or response_str.startswith("nochg"):
82 | return True
83 | elif response_str.startswith("nohost"):
84 | self.logger.error("Hostname %s does not exist under No-IP account", domain)
85 | elif response_str.startswith("badauth"):
86 | self.logger.error("Invalid No-IP username/password combination")
87 | elif response_str.startswith("badagent"):
88 | self.logger.error("No-IP client disabled")
89 | elif response_str.startswith("!donator"):
90 | self.logger.error("Feature not available for No-IP free account")
91 | elif response_str.startswith("abuse"):
92 | self.logger.error("No-IP account blocked due to abuse")
93 | else:
94 | self.logger.error("Unexpected No-IP API response: %s", response_str)
95 | else:
96 | self.logger.error("Empty response from No-IP API")
97 |
98 | except Exception as e:
99 | self.logger.error("Error updating No-IP record for %s: %s", domain, e)
100 |
101 | return False
102 |
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | This is a Python-based Dynamic DNS (DDNS) client that automatically updates DNS records to match the current IP address. It supports multiple DNS providers, IPv4/IPv6, and various configuration methods. Please follow these guidelines when contributing:
2 |
3 | ## Code Standards
4 |
5 | ### Required Before Each Commit
6 | - Follow Python coding standards as defined in `.github/instructions/python.instructions.md`
7 | - Use only Python standard library modules (no external dependencies)
8 | - Ensure Python 2.7 and 3.x compatibility
9 | - Run tests before committing to ensure all functionality works correctly
10 | - Format and lint code using `ruff` before each commit:
11 | ```bash
12 | ruff check --fix --unsafe-fixes .
13 | ruff format .
14 | ```
15 |
16 | ### Development Flow
17 | - Test: `python -m unittest discover tests` or `python -m pytest tests/`
18 | - Lint: `ruff check --fix --unsafe-fixes .`
19 | - Format: `ruff format .`
20 |
21 | ### Add a New DNS Provider
22 |
23 | Follow the steps below to add a new DNS provider:
24 | - [Python coding standards](./instructions/python.instructions.md)
25 | - [Provider development guide](../doc/dev/provider.md)
26 | - Provider documentation template:[aliyun](../doc/provider/aliyun.md) and [dnspod](../doc/provider/dnspod.md)
27 | - keep the template structure and fill in the required information
28 | - remove the not applicable sections or fields
29 | - in english doc linke the documentation to the english version documentations
30 | - don't change the ref link [参考配置](../json.md#ipv4-ipv6) in the template. In english documentation, use the english version link [Reference](../json.en.md#ipv4-ipv6)
31 |
32 | ## Repository Structure
33 | - `ddns/`: Main application code
34 | - `provider/`: DNS provider implementations (DNSPod, AliDNS, CloudFlare, etc.)
35 | - `config/`: Configuration management (loading, parsing, validation)
36 | - `util/`: Utility functions (HTTP client, configuration management, IP detection)
37 | - `tests/`: Unit tests using unittest framework
38 | - `doc/`: Documentation and user guides
39 | - `providers/`: Provider-specific configuration guides
40 | - `dev/`: Developer documentation
41 | - `schema/`: JSON configuration schemas
42 | - `docker/`: Docker-related files and scripts
43 |
44 | ## Key Guidelines
45 | 1. Follow Python best practices and maintain cross-platform compatibility
46 | 2. Use only standard library modules to ensure self-contained operation
47 | 3. Maintain Python 2.7 compatibility (avoid f-strings, async/await)
48 | 4. Write comprehensive unit tests for all new functionality
49 | 5. Use proper logging and error handling throughout the codebase
50 | 6. Document public APIs and configuration options thoroughly
51 | 7. Test provider implementations against real APIs when possible
52 | 8. Ensure all DNS provider classes inherit from BaseProvider or SimpleProvider
53 |
54 | ## Testing Guidelines
55 |
56 | ### Test Structure
57 | - Place tests in `tests/` directory using `test_*.py` naming
58 | - import unittest, patch, MagicMock
59 | - for all provider tests, use the `from base_test import BaseProviderTestCase, unittest, patch, MagicMock`
60 | - for all other tests, use `from __init__ import unittest, patch, MagicMock ` to ensure compatibility with both unittest and pytest
61 | - Use unittest (default) or pytest (optional)
62 |
63 | ### Basic provider Test Template
64 | ```python
65 | from base_test import BaseProviderTestCase, patch, MagicMock
66 | from ddns.provider.example import ExampleProvider
67 |
68 | class TestExampleProvider(BaseProviderTestCase):
69 | def setUp(self):
70 | super(TestExampleProvider, self).setUp()
71 | self.provider = ExampleProvider(self.id, self.token)
72 |
73 | @patch.object(ExampleProvider, "_http")
74 | def test_set_record_success(self, mock_http):
75 | mock_http.return_value = {"success": True}
76 | result = self.provider.set_record("test.com", "1.2.3.4")
77 | self.assertTrue(result)
78 | ```
79 |
80 | ### Test Requirements
81 | 1. **Unit tests**: All public methods must have tests with mocked HTTP calls
82 | 2. **Error handling**: Test invalid inputs and network failures
83 | 3. **Python 2.7 compatible**: Use standard library only
84 | 4. **Documentation**: Include docstrings for test methods
85 |
86 | ### Running Tests
87 | ```bash
88 | # Run all tests (recommended)
89 | python -m unittest discover tests -v
90 |
91 | # Run specific provider
92 | python -m unittest tests.test_provider_example -v
93 | ```
94 |
95 | See existing tests in `tests/` directory for detailed examples.
96 |
--------------------------------------------------------------------------------
/ddns/provider/dnspod.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | DNSPOD API
4 | @doc: https://docs.dnspod.cn/api/
5 | @author: NewFuture
6 | """
7 |
8 | from ._base import BaseProvider, TYPE_FORM
9 |
10 |
11 | class DnspodProvider(BaseProvider):
12 | """
13 | DNSPOD API
14 | DNSPOD 接口解析操作库
15 | """
16 |
17 | endpoint = "https://dnsapi.cn"
18 | content_type = TYPE_FORM
19 |
20 | DefaultLine = "默认"
21 |
22 | def _request(self, action, extra=None, **params):
23 | # type: (str, dict | None, **(str | int | bytes | bool | None)) -> dict
24 | """
25 | 发送请求数据
26 |
27 | Send request to DNSPod API.
28 | Args:
29 | action (str): API 动作/Action
30 | extra (dict|None): 额外参数/Extra params
31 | params (dict): 其它参数/Other params
32 | Returns:
33 | dict: 响应数据/Response data
34 | """
35 | # 过滤掉None参数
36 | if extra:
37 | params.update(extra)
38 | params = {k: v for k, v in params.items() if v is not None}
39 | params.update({"login_token": "{0},{1}".format(self.id, self.token), "format": "json"})
40 | data = self._http("POST", "/" + action, body=params)
41 | if data and data.get("status", {}).get("code") == "1": # 请求成功
42 | return data
43 | else: # 请求失败
44 | error_msg = "Unknown error"
45 | if data and isinstance(data, dict):
46 | error_msg = data.get("status", {}).get("message", "Unknown error")
47 | self.logger.warning("DNSPod API error: %s", error_msg)
48 | return data
49 |
50 | def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
51 | # type: (str, str, str, str, str, int | str | None, str | None, dict) -> bool
52 | """https://docs.dnspod.cn/api/add-record/"""
53 | res = self._request(
54 | "Record.Create",
55 | extra=extra,
56 | domain_id=zone_id,
57 | sub_domain=subdomain,
58 | value=value,
59 | record_type=record_type,
60 | record_line=line or self.DefaultLine,
61 | ttl=ttl,
62 | )
63 | record = res and res.get("record")
64 | if record: # 记录创建成功
65 | self.logger.info("Record created: %s", record)
66 | return True
67 | else: # 记录创建失败
68 | self.logger.error("Failed to create record: %s", res)
69 | return False
70 |
71 | def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
72 | # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
73 | """https://docs.dnspod.cn/api/modify-records/"""
74 | record_line = (line or old_record.get("line") or self.DefaultLine).replace("Default", "default")
75 | res = self._request(
76 | "Record.Modify",
77 | domain_id=zone_id,
78 | record_id=old_record.get("id"),
79 | sub_domain=old_record.get("name"),
80 | record_type=record_type,
81 | value=value,
82 | record_line=record_line,
83 | extra=extra,
84 | )
85 | record = res and res.get("record")
86 | if record: # 记录更新成功
87 | self.logger.debug("Record updated: %s", record)
88 | return True
89 | else: # 记录更新失败
90 | self.logger.error("Failed to update record: %s", res)
91 | return False
92 |
93 | def _query_zone_id(self, domain):
94 | # type: (str) -> str | None
95 | """查询域名信息 https://docs.dnspod.cn/api/domain-info/"""
96 | res = self._request("Domain.Info", domain=domain)
97 | if res and isinstance(res, dict):
98 | return res.get("domain", {}).get("id")
99 | return None
100 |
101 | def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
102 | # type: (str, str, str, str, str | None, dict) -> dict | None
103 | """查询记录 list 然后逐个查找 https://docs.dnspod.cn/api/record-list/"""
104 | res = self._request("Record.List", domain_id=zone_id, sub_domain=subdomain, record_type=record_type, line=line)
105 | # length="3000"
106 | records = res.get("records", [])
107 | n = len(records)
108 | if not n:
109 | self.logger.warning("No record found for [%s] %s<%s>(line: %s)", zone_id, subdomain, record_type, line)
110 | return None
111 | if n > 1:
112 | self.logger.warning("%d records found for %s<%s>(%s):\n %s", n, subdomain, record_type, line, records)
113 | return next((r for r in records if r.get("name") == subdomain), None)
114 | return records[0]
115 |
--------------------------------------------------------------------------------
/doc/providers/aliesa.md:
--------------------------------------------------------------------------------
1 | # 阿里云边缘安全加速 (ESA) 配置指南
2 |
3 | ## 概述
4 |
5 | 阿里云边缘安全加速(ESA)是阿里云提供的边缘安全加速服务,支持 DNS 记录的动态管理。本 DDNS 项目通过 AccessKey ID 和 AccessKey Secret 更新 ESA DNS 记录。
6 |
7 | 官网链接:
8 |
9 | - 官方网站:
10 | - 服务商控制台:
11 |
12 | ## 认证信息
13 |
14 | ### AccessKey 认证
15 |
16 | 使用阿里云 AccessKey ID 和 AccessKey Secret 进行认证。
17 |
18 | #### 获取认证信息
19 |
20 | 1. 登录 [阿里云控制台](https://ecs.console.aliyun.com/)
21 | 2. 访问 [AccessKey管理](https://usercenter.console.aliyun.com/#/manage/ak)
22 | 3. 点击"创建AccessKey"按钮
23 | 4. 复制生成的 **AccessKey ID** 和 **AccessKey Secret**,请妥善保存
24 | 5. 确保账号具有边缘安全加速 (`AliyunESAFullAccess`) 的操作权限
25 |
26 | ```json
27 | {
28 | "dns": "aliesa",
29 | "id": "your_access_key_id", // AccessKey ID
30 | "token": "your_access_key_secret" // AccessKey Secret
31 | }
32 | ```
33 |
34 | ## 权限要求
35 |
36 | 确保使用的阿里云账号具有以下权限:
37 |
38 | - **AliyunESAFullAccess**:边缘安全加速完全访问权限(推荐)
39 | - **ESA站点查询权限 + ESA DNS记录管理权限**:精细化权限控制
40 | - `esa:ListSites`:查询站点列表
41 | - `esa:ListRecords`:查询 DNS 记录
42 | - `esa:CreateRecord`:创建 DNS 记录
43 | - `esa:UpdateRecord`:更新 DNS 记录
44 |
45 | 可以在 [RAM控制台](https://ram.console.aliyun.com/policies) 中查看和配置权限。
46 |
47 | ## 完整配置示例
48 |
49 | ```json
50 | {
51 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
52 | "dns": "aliesa", // 当前服务商
53 | "id": "your_access_key_id", // AccessKey ID
54 | "token": "your_access_key_secret", // AccessKey Secret
55 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
56 | "index6": "public", // IPv6地址来源
57 | "ipv4": ["ddns.newfuture.cc"], // IPv4 域名
58 | "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"], // IPv6 域名
59 | "endpoint": "https://esa.cn-hangzhou.aliyuncs.com", // API端点
60 | "ttl": 600 // DNS记录TTL(秒)
61 | }
62 | ```
63 |
64 | ### 参数说明
65 |
66 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
67 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
68 | | dns | 服务商标识 | 字符串 | `aliesa` | 无 | 服务商参数 |
69 | | id | 认证 ID | 字符串 | 阿里云 AccessKey ID | 无 | 服务商参数 |
70 | | token | 认证密钥 | 字符串 | 阿里云 AccessKey Secret | 无 | 服务商参数 |
71 | | index4 | IPv4 来源 | 数组 | [参考](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
72 | | index6 | IPv6 来源 | 数组 | [参考](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
73 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
74 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
75 | | endpoint| API 端点 | URL | [参考下方](#endpoint) | `https://esa.cn-hangzhou.aliyuncs.com` | 服务商参数 |
76 | | ttl | TTL 时间 | 整数(秒) | 30~86400 | 1(自动) | 服务商参数 |
77 | | proxy | 代理设置 | 数组 | [参考](../config/json.md#proxy) | 无 | 公用网络 |
78 | | ssl | SSL 验证方式 | 布尔/字符串 | `auto`、`true`、`false` | `auto` | 公用网络 |
79 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
80 | | log | 日志配置 | 对象 | [参考](../config/json.md#log) | 无 | 公用配置 |
81 |
82 | > **参数类型说明**:
83 | >
84 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
85 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数参数
86 | > - **服务商参数**:前服务商支持,值与当前服务商相关
87 |
88 | ### endpoint
89 |
90 | 阿里云 ESA 支持多个区域端点,可根据区域和网络环境选择最优节点:
91 |
92 | #### 国内节点
93 |
94 | - **华东(杭州)**:`https://esa.cn-hangzhou.aliyuncs.com`(默认)
95 |
96 | #### 国际节点
97 |
98 | - **亚太东南1(新加坡)**:`https://esa.ap-southeast-1.aliyuncs.com`
99 |
100 | ## 故障排除
101 |
102 | ### 调试模式
103 |
104 | 启用调试日志查看详细信息:
105 |
106 | ```sh
107 | ddns -c config.json --debug
108 | ```
109 |
110 | ### 常见问题
111 |
112 | #### "Site not found for domain"
113 |
114 | - 检查域名是否已添加到ESA服务
115 | - 确认域名格式正确(不包含协议前缀)
116 | - 验证AccessKey权限
117 |
118 | #### "Failed to create/update record"
119 |
120 | - 检查DNS记录类型是否支持
121 | - 确认记录值格式正确
122 | - 验证TTL值在允许范围内
123 |
124 | #### "API调用失败"
125 |
126 | - 检查AccessKey ID和Secret是否正确
127 | - 确认网络连接正常
128 | - 查看详细错误日志
129 |
130 | ## 支持与资源
131 |
132 | - [阿里云ESA产品文档](https://help.aliyun.com/product/122312.html)
133 | - [阿里云ESA API文档](https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-overview)
134 | - [阿里云ESA控制台](https://esa.console.aliyun.com/)
135 | - [阿里云技术支持](https://selfservice.console.aliyun.com/ticket)
136 |
137 | > **建议**:使用 RAM 子账号并定期轮换 AccessKey,提升账号安全性。
138 |
--------------------------------------------------------------------------------
/ddns/scheduler/cron.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | Cron-based task scheduler for Unix-like systems
4 | @author: NewFuture
5 | """
6 |
7 | import os
8 | import subprocess
9 | import tempfile
10 |
11 | from ..util.fileio import write_file
12 | from ..util.try_run import try_run
13 | from ._base import BaseScheduler
14 |
15 |
16 | class CronScheduler(BaseScheduler):
17 | """Cron-based task scheduler for Unix-like systems"""
18 |
19 | SCHEDULER_NAME = "cron"
20 |
21 | KEY = "# DDNS:"
22 |
23 | def _update_crontab(self, lines): # type: (list[str]) -> bool
24 | """Update crontab with new content"""
25 | try:
26 | temp_path = tempfile.mktemp(suffix=".cron")
27 | write_file(temp_path, u"\n".join(lines) + u"\n") # fmt: skip
28 | subprocess.check_call(["crontab", temp_path])
29 | os.unlink(temp_path)
30 | return True
31 | except Exception as e:
32 | self.logger.error("Failed to update crontab: %s", e)
33 | return False
34 |
35 | def is_installed(self, crontab_content=None): # type: (str | None) -> bool
36 | result = crontab_content or try_run(["crontab", "-l"], logger=self.logger) or ""
37 | return self.KEY in result
38 |
39 | def get_status(self):
40 | status = {"scheduler": "cron", "installed": False} # type: dict[str, str | bool | int | None]
41 | # Get crontab content once and reuse it for all checks
42 | crontab_content = try_run(["crontab", "-l"], logger=self.logger) or ""
43 | lines = crontab_content.splitlines()
44 | line = next((i for i in lines if self.KEY in i), "").strip()
45 |
46 | if line: # Task is installed
47 | status["installed"] = True
48 | status["enabled"] = bool(line and not line.startswith("#"))
49 | else: # Task not installed
50 | status["enabled"] = False
51 |
52 | cmd_groups = line.split(self.KEY, 1) if line else ["", ""]
53 | parts = cmd_groups[0].strip(" #\t").split() if cmd_groups[0] else []
54 | status["interval"] = int(parts[0][2:]) if len(parts) >= 5 and parts[0].startswith("*/") else None
55 | status["command"] = " ".join(parts[5:]) if len(parts) >= 6 else None
56 | status["description"] = cmd_groups[1].strip() if len(cmd_groups) > 1 else None
57 |
58 | return status
59 |
60 | def install(self, interval, ddns_args=None):
61 | ddns_commands = self._build_ddns_command(ddns_args)
62 | # Convert array to properly quoted command string for cron
63 | ddns_command = self._quote_command_array(ddns_commands)
64 | description = self._get_description()
65 | cron_entry = '*/{} * * * * cd "{}" && {} # DDNS: {}'.format(interval, os.getcwd(), ddns_command, description)
66 |
67 | crontext = try_run(["crontab", "-l"], logger=self.logger) or ""
68 | lines = [line for line in crontext.splitlines() if self.KEY not in line]
69 | lines.append(cron_entry)
70 |
71 | if self._update_crontab(lines):
72 | return True
73 | else:
74 | self.logger.error("Failed to install DDNS cron job")
75 | return False
76 |
77 | def uninstall(self):
78 | return self._modify_cron_lines("uninstall")
79 |
80 | def enable(self):
81 | return self._modify_cron_lines("enable")
82 |
83 | def disable(self):
84 | return self._modify_cron_lines("disable")
85 |
86 | def _modify_cron_lines(self, action): # type: (str) -> bool
87 | """Helper to enable, disable, or uninstall cron lines"""
88 | crontext = try_run(["crontab", "-l"], logger=self.logger)
89 | if not crontext or self.KEY not in crontext:
90 | self.logger.info("No crontab found")
91 | return False
92 |
93 | modified_lines = []
94 | for line in crontext.rstrip("\n").splitlines():
95 | if self.KEY not in line:
96 | modified_lines.append(line)
97 | elif action == "uninstall":
98 | continue # Skip DDNS lines (remove them)
99 | elif action == "enable" and line.strip().startswith("#"):
100 | uncommented = line.lstrip(" #\t").lstrip() # Enable: uncomment the line
101 | modified_lines.append(uncommented if uncommented else line)
102 | elif action == "disable" and not line.strip().startswith("#"):
103 | modified_lines.append("# " + line) # Disable: comment the line
104 | else:
105 | raise ValueError("Invalid action: {}".format(action))
106 |
107 | if self._update_crontab(modified_lines):
108 | return True
109 | else:
110 | self.logger.error("Failed to %s DDNS cron job", action)
111 | return False
112 |
--------------------------------------------------------------------------------
/doc/providers/dnspod.md:
--------------------------------------------------------------------------------
1 | # DNSPod 中国版 配置指南
2 |
3 | ## 概述
4 |
5 | DNSPod (dnspod.cn) 是腾讯云旗下的权威 DNS 解析服务,在中国大陆地区广泛使用,支持动态 DNS 记录的创建与更新。本 DDNS 项目支持多种认证方式连接 DNSPod 进行动态 DNS 记录管理。
6 |
7 | 官网链接:
8 |
9 | - 官方网站:
10 | - 服务商控制台:
11 |
12 | ## 认证信息
13 |
14 | ### 1. API Token 认证(推荐)
15 |
16 | API Token 方式更安全,是 DNSPod 推荐的集成方法。
17 |
18 | #### 获取认证信息
19 |
20 | 1. 登录 [DNSPod 控制台](https://console.dnspod.cn/)
21 | 2. 进入"用户中心" > "API 密钥"或访问
22 | 3. 点击"创建密钥",填写描述,选择域名管理权限,完成创建
23 | 4. 复制 **ID**(数字)和 **Token**(字符串),密钥只显示一次,请妥善保存
24 |
25 | ```json
26 | {
27 | "dns": "dnspod",
28 | "id": "123456", // DNSPod API Token ID
29 | "token": "YOUR_DNSPOD_TOKEN" // DNSPod API Token 密钥
30 | }
31 | ```
32 |
33 | ### 2. 邮箱密码认证(不推荐)
34 |
35 | 使用 DNSPod 账号邮箱和密码,**安全性较低**,仅建议特殊场景使用。
36 |
37 | ```json
38 | {
39 | "dns": "dnspod",
40 | "id": "your-email@example.com", // DNSPod 账号邮箱
41 | "token": "your-account-password" // DNSPod 账号密码
42 | }
43 | ```
44 |
45 | ### 3. 腾讯云 AccessKey 方式
46 |
47 | 对于使用腾讯云 AccessKey 的用户,请参考 [腾讯云 DNSPod 配置指南](tencentcloud.md)。
48 |
49 | ## 完整配置示例
50 |
51 | ```json
52 | {
53 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
54 | "dns": "dnspod", // 当前服务商
55 | "id": "123456", // DNSPod API Token ID
56 | "token": "abcdef1234567890abcdef1234567890", // DNSPod API Token 密钥
57 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
58 | "index6": "public", // IPv6地址来源
59 | "ipv4": ["ddns.newfuture.cc"], // IPv4 域名
60 | "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"], // IPv6 域名
61 | "line": "默认", // 解析线路
62 | "ttl": 600 // DNS记录TTL(秒)
63 | }
64 | ```
65 |
66 | ### 参数说明
67 |
68 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
69 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
70 | | dns | 服务商标识 | 字符串 | `dnspod` | 无 | 服务商参数 |
71 | | id | 认证 ID | 字符串 | DNSPod API Token ID 或邮箱 | 无 | 服务商参数 |
72 | | token | 认证密钥 | 字符串 | DNSPod API Token 密钥或密码 | 无 | 服务商参数 |
73 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
74 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
75 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
76 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
77 | | line | 解析线路 | 字符串 | [参考下方](#line) | `默认` | 服务商参数 |
78 | | ttl | TTL 时间 | 整数(秒) | [参考下方](#ttl) | `600` | 服务商参数 |
79 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
80 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
81 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
82 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
83 |
84 | > **参数类型说明**:
85 | >
86 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
87 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数
88 | > - **服务商参数**:当前服务商支持,值与当前服务商相关
89 | > **注意**:`ttl` 和 `line` 不同套餐支持的值可能不同。
90 |
91 | ### ttl
92 |
93 | `ttl` 参数指定 DNS 记录的生存时间(TTL),单位为秒。DNSPod 支持的 TTL 范围为 1 到 604800 秒(即 7 天)。如果不设置,则使用默认值。
94 |
95 | | 套餐类型 | 支持的 TTL 范围(秒) |
96 | | :------ | :------------------- |
97 | | 免费版 | 600 ~ 604800 |
98 | | 专业版 | 60 ~ 604800 |
99 | | 企业版 | 1 ~ 604800 |
100 | | 尊享版 | 1 ~ 604800 |
101 |
102 | > 参考:[DNSPod TTL 说明](https://docs.dnspod.cn/dns/help-ttl/)
103 |
104 | ### line
105 |
106 | `line` 参数指定 DNS 解析线路,DNSPod 支持的线路:
107 |
108 | | 线路标识 | 说明 |
109 | | :-------------- | :----------- |
110 | | 默认 | 默认 |
111 | | 电信 | 中国电信 |
112 | | 联通 | 中国联通 |
113 | | 移动 | 中国移动 |
114 | | 教育网 | 中国教育网 |
115 | | 搜索引擎 | 搜索引擎 |
116 | | 境外 | 境外 |
117 |
118 | > 更多线路参考:[DNSPod 解析线路说明](https://docs.dnspod.cn/dns/dns-record-line)
119 |
120 | ## 故障排除
121 |
122 | ### 调试模式
123 |
124 | 启用调试日志查看详细信息:
125 |
126 | ```sh
127 | ddns -c config.json --debug
128 | ```
129 |
130 | ### 常见问题
131 |
132 | - **认证失败**:检查 API Token 或邮箱/密码是否正确,确认有域名管理权限
133 | - **域名未找到**:确保域名已添加到 DNSPod 账号,配置拼写无误,域名处于活跃状态
134 | - **记录创建失败**:检查子域名是否有冲突记录,TTL 设置合理,确认有修改权限
135 | - **请求频率限制**:DNSPod 有 API 调用频率限制,降低请求频率
136 |
137 | ## 支持与资源
138 |
139 | - [DNSPod 产品文档](https://docs.dnspod.cn/)
140 | - [DNSPod API 参考](https://docs.dnspod.cn/api/)
141 | - [DNSPod 控制台](https://console.dnspod.cn/)
142 | - [腾讯云 DNSPod (AccessKey 方式)](./tencentcloud.md)
143 |
144 | > **建议**:推荐使用 API Token 方式,提升安全性与管理便捷性,避免使用邮箱密码方式。
145 |
--------------------------------------------------------------------------------
/ddns/scheduler/schtasks.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | schtasks-based task scheduler
4 | @author: NewFuture
5 | """
6 |
7 | import os
8 | import re
9 | import tempfile
10 |
11 | from ..util.try_run import try_run
12 | from ._base import BaseScheduler
13 |
14 |
15 | class SchtasksScheduler(BaseScheduler):
16 | """schtasks-based task scheduler"""
17 |
18 | NAME = "DDNS"
19 |
20 | def _schtasks(self, *args):
21 | """Helper to run schtasks commands with consistent error handling"""
22 | result = try_run(["schtasks"] + list(args), logger=self.logger)
23 | return result is not None
24 |
25 | def _extract_xml(self, xml_text, tag_name):
26 | """Extract XML tag content using regex for better performance and flexibility"""
27 | pattern = r"<{0}[^>]*>(.*?){0}>".format(re.escape(tag_name))
28 | match = re.search(pattern, xml_text, re.DOTALL)
29 | return match.group(1).strip() if match else None
30 |
31 | def is_installed(self):
32 | result = try_run(["schtasks", "/query", "/tn", self.NAME], logger=self.logger) or ""
33 | return self.NAME in result
34 |
35 | def get_status(self):
36 | # Use XML format for language-independent parsing
37 | task_xml = try_run(["schtasks", "/query", "/tn", self.NAME, "/xml"], logger=self.logger)
38 | status = {"scheduler": "schtasks", "installed": bool(task_xml)}
39 |
40 | if not task_xml:
41 | return status # Task not installed, return minimal status
42 |
43 | status["enabled"] = self._extract_xml(task_xml, "Enabled") != "false"
44 | command = self._extract_xml(task_xml, "Command")
45 | arguments = self._extract_xml(task_xml, "Arguments")
46 | status["command"] = "{} {}".format(command, arguments) if command and arguments else command
47 |
48 | # Parse interval: PT10M -> 10, fallback to original string
49 | interval_str = self._extract_xml(task_xml, "Interval")
50 | interval_match = re.search(r"PT(\d+)M", interval_str) if interval_str else None
51 | status["interval"] = int(interval_match.group(1)) if interval_match else interval_str
52 |
53 | # Show description if exists, otherwise show installation date
54 | description = self._extract_xml(task_xml, "Description") or self._extract_xml(task_xml, "Date")
55 | if description:
56 | status["description"] = description
57 | return status
58 |
59 | def install(self, interval, ddns_args=None):
60 | # Build command line as array: prefer pythonw for script mode, or compiled exe directly
61 | cmd_array = self._build_ddns_command(ddns_args)
62 | workdir = os.getcwd()
63 |
64 | # Split array into executable and arguments for schtasks XML format
65 | # For Windows scheduler, prefer pythonw.exe to avoid console window
66 | executable = cmd_array[0].replace("python.exe", "pythonw.exe")
67 | arguments = self._quote_command_array(cmd_array[1:]) if len(cmd_array) > 1 else ""
68 |
69 | # Create XML task definition with working directory support
70 | description = self._get_description()
71 |
72 | # Use template string to generate Windows Task Scheduler XML
73 | xml_content = """
74 |
75 |
76 | {description}
77 |
78 |
79 |
80 | 1900-01-01T00:00:00
81 |
82 | PT{interval}M
83 |
84 | true
85 |
86 |
87 |
88 |
89 | {exe}
90 | {arguments}
91 | {dir}
92 |
93 |
94 |
95 | IgnoreNew
96 | false
97 | false
98 |
99 | """.format(description=description, interval=interval, exe=executable, arguments=arguments, dir=workdir)
100 |
101 | # Write XML to temp file and use it to create task
102 | with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
103 | f.write(xml_content)
104 | xml_file = f.name
105 |
106 | try:
107 | success = self._schtasks("/Create", "/XML", xml_file, "/TN", self.NAME, "/F")
108 | return success
109 | finally:
110 | os.unlink(xml_file)
111 |
112 | def uninstall(self):
113 | success = self._schtasks("/Delete", "/TN", self.NAME, "/F")
114 | return success
115 |
116 | def enable(self):
117 | return self._schtasks("/Change", "/TN", self.NAME, "/Enable")
118 |
119 | def disable(self):
120 | return self._schtasks("/Change", "/TN", self.NAME, "/Disable")
121 |
--------------------------------------------------------------------------------
/doc/providers/51dns.md:
--------------------------------------------------------------------------------
1 | # 51DNS(dns.com) 配置指南
2 |
3 | ## 概述
4 |
5 | 51DNS (DNSCOM)(原dns.com,现51dns.com)是中国知名的域名解析服务商,提供权威 DNS 解析服务,支持动态 DNS 记录的创建与更新。本 DDNS 项目通过 API Key 和 Secret Key 进行 API 认证。
6 |
7 | > ⚠️ **注意**:51DNS(DNSCOM) Provider 目前处于**待验证**状态,缺少充分的真实环境测试。请通过 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 反馈。
8 |
9 | 官方网站:
10 |
11 | ## 认证信息
12 |
13 | ### API Key + Secret Key 认证
14 |
15 | 51DNS 使用 API Key 和 Secret Key 进行 API 认证,这是官方推荐的认证方式。
16 |
17 | #### 获取认证信息
18 |
19 | 1. 登录 [51DNS/DNS.COM 控制台](https://www.51dns.com/)
20 | 2. 进入"API管理"页面
21 | 3. 点击"创建API密钥"
22 | 4. 记录生成的 **API Key** 和 **Secret Key**,请妥善保存
23 |
24 | ```json
25 | {
26 | "dns": "dnscom",
27 | "id": "your_api_key", // 51DNS API Key
28 | "token": "your_secret_key" // 51DNS Secret Key
29 | }
30 | ```
31 |
32 | ## 完整配置示例
33 |
34 | ```json
35 | {
36 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
37 | "dns": "dnscom", // 当前服务商
38 | "id": "your_api_key", // 51DNS API Key
39 | "token": "your_secret_key", // 51DNS Secret Key
40 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
41 | "index6": "public", // IPv6地址来源
42 | "ipv4": ["ddns.newfuture.cc"], // IPv4 域名
43 | "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"], // IPv6 域名
44 | "line": "1", // 解析线路
45 | "ttl": 600 // DNS记录TTL(秒)
46 | }
47 | ```
48 |
49 | ### 参数说明
50 |
51 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
52 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
53 | | dns | 服务商标识 | 字符串 | `dnscom` | 无 | 服务商参数 |
54 | | id | 认证 ID | 字符串 | 51DNS API Key | 无 | 服务商参数 |
55 | | token | 认证密钥 | 字符串 | 51DNS Secret Key | 无 | 服务商参数 |
56 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
57 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
58 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
59 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
60 | | line | 解析线路 | 字符串 | [参考下方](#line) | `1` | 服务商参数 |
61 | | ttl | TTL 时间 | 整数(秒) | [参考下方](#ttl) | `600` | 服务商参数 |
62 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
63 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
64 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
65 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
66 |
67 | > **参数类型说明**:
68 | >
69 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
70 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数参数
71 | > - **服务商参数**:前服务商支持,值与当前服务商相关
72 | >
73 | > **注意**:`ttl` 和 `line` 不同套餐支持的值可能不同。
74 |
75 | ### ttl
76 |
77 | `ttl` 参数指定 DNS 记录的生存时间(TTL),单位为秒。51dns 支持的 TTL 范围为 60 到 86400 秒(即 1 天)。如果不设置,则使用默认值。
78 |
79 | | 套餐类型 | 支持的 TTL 范围(秒) |
80 | | :---------- | :-------------------: |
81 | | 免费版 | 600 - 86400 |
82 | | 专业版 | 60 - 86400 |
83 | | 企业版 | 10 - 86400 |
84 | | 旗舰版 | 1 - 86400 |
85 |
86 | > **注意**:具体TTL范围请参考[51dns官方文档](https://www.51dns.com/service.html)
87 |
88 | ### line
89 |
90 | `line` 参数指定 DNS 解析线路,51dns 支持的线路:
91 |
92 | | 线路标识 | 说明 |
93 | | :-------------- | :----------- |
94 | | 1 | 默认 |
95 | | 2 | 中国电信 |
96 | | 3 | 中国联通 |
97 | | 4 | 中国移动 |
98 | | 5 | 海外 |
99 | | 6 | 教育网 |
100 |
101 | > 更多线路参考:参考文档 [官方文档 ViewID](https://www.51dns.com/document/api/index.html)
102 |
103 | ## 故障排除
104 |
105 | ### 调试模式
106 |
107 | 启用调试日志查看详细信息:
108 |
109 | ```sh
110 | ddns -c config.json --debug
111 | ```
112 |
113 | ### 常见问题
114 |
115 | - **认证失败**:检查 API Key 和 Secret Key 是否正确,确认 API 密钥状态为启用
116 | - **域名未找到**:确保域名已添加到 51dns 账号,配置拼写无误,域名处于活跃状态
117 | - **记录创建失败**:检查子域名是否有冲突记录,TTL 设置合理,确认有修改权限
118 | - **请求频率限制**:51dns 有 API 调用频率限制(每分钟最多100次),降低请求频率
119 |
120 | ### API 错误代码
121 |
122 | | 错误代码 | 说明 | 解决方案 |
123 | | :------ | :----------- | :------------- |
124 | | 0 | 成功 | 操作成功 |
125 | | 1 | 参数错误 | 检查请求参数 |
126 | | 2 | 认证失败 | 检查API密钥 |
127 | | 3 | 权限不足 | 检查API权限 |
128 | | 4 | 记录不存在 | 检查域名和记录 |
129 | | 5 | 域名不存在 | 检查域名配置 |
130 |
131 | ## 支持与资源
132 |
133 | - [51dns 官网](https://www.51dns.com/)
134 | - [51dns API 文档](https://www.51dns.com/document/api/index.html)
135 | - [51dns 控制台](https://www.51dns.com/)
136 |
137 | > ⚠️ **待验证状态**:51dns Provider 缺少充分的真实环境测试,建议在生产环境使用前进行充分测试。如遇问题请通过 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 反馈。
138 |
--------------------------------------------------------------------------------
/ddns/provider/aliesa.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | AliESA API
4 | 阿里云边缘安全加速(ESA) DNS 解析操作库
5 | @author: NewFuture, GitHub Copilot
6 | """
7 |
8 | from time import strftime
9 |
10 | from ._base import TYPE_JSON, join_domain
11 | from .alidns import AliBaseProvider
12 |
13 |
14 | class AliesaProvider(AliBaseProvider):
15 | """阿里云边缘安全加速(ESA) DNS Provider"""
16 |
17 | endpoint = "https://esa.cn-hangzhou.aliyuncs.com"
18 | api_version = "2024-09-10" # ESA API版本
19 | content_type = TYPE_JSON
20 | remark = "Managed by DDNS %s" % strftime("%Y-%m-%d %H:%M:%S")
21 |
22 | def _query_zone_id(self, domain):
23 | # type: (str) -> str | None
24 | """
25 | 查询站点ID
26 | https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listsites
27 | """
28 | res = self._request(method="GET", action="ListSites", SiteName=domain, PageSize=500)
29 | sites = res.get("Sites", [])
30 |
31 | for site in sites:
32 | if site.get("SiteName") == domain:
33 | site_id = site.get("SiteId")
34 | self.logger.debug("Found site ID %s for domain %s", site_id, domain)
35 | return site_id
36 |
37 | self.logger.error("Site not found for domain: %s", domain)
38 | return None
39 |
40 | def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
41 | # type: (str, str, str, str, str | None, dict) -> dict | None
42 | """
43 | 查询DNS记录
44 | https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listrecords
45 | """
46 | full_domain = join_domain(subdomain, main_domain)
47 | res = self._request(
48 | method="GET",
49 | action="ListRecords",
50 | SiteId=int(zone_id),
51 | RecordName=full_domain,
52 | Type=self._get_type(record_type),
53 | RecordMatchType="exact", # 精确匹配
54 | PageSize=100,
55 | )
56 |
57 | records = res.get("Records", [])
58 | if len(records) == 0:
59 | self.logger.warning("No records found for [%s] with %s <%s>", zone_id, full_domain, record_type)
60 | return None
61 |
62 | # 返回第一个匹配的记录
63 | record = records[0]
64 | self.logger.debug("Found record: %s", record)
65 | return record
66 |
67 | def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
68 | # type: (str, str, str, str, str, int | str | None, str | None, dict) -> bool
69 | """
70 | 创建DNS记录
71 | https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord
72 | """
73 | full_domain = join_domain(subdomain, main_domain)
74 | extra["Comment"] = extra.get("Comment", self.remark)
75 | extra["BizName"] = extra.get("BizName", "web")
76 | extra["Proxied"] = extra.get("Proxied", True)
77 | data = self._request(
78 | method="POST",
79 | action="CreateRecord",
80 | SiteId=int(zone_id),
81 | RecordName=full_domain,
82 | Type=self._get_type(record_type),
83 | Data={"Value": value},
84 | Ttl=ttl or 1,
85 | **extra
86 | ) # fmt: skip
87 |
88 | if data and data.get("RecordId"):
89 | self.logger.info("Record created: %s", data)
90 | return True
91 |
92 | self.logger.error("Failed to create record: %s", data)
93 | return False
94 |
95 | def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
96 | # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
97 | """
98 | 更新DNS记录
99 | https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-updaterecord
100 | """
101 | # 检查是否需要更新
102 | if (
103 | old_record.get("Data", {}).get("Value") == value
104 | and old_record.get("RecordType") == self._get_type(record_type)
105 | and (not ttl or old_record.get("Ttl") == ttl)
106 | ):
107 | self.logger.warning("No changes detected, skipping update for record: %s", old_record.get("RecordName"))
108 | return True
109 |
110 | extra["Comment"] = extra.get("Comment", self.remark)
111 | extra["Proxied"] = extra.get("Proxied", old_record.get("Proxied"))
112 | data = self._request(
113 | method="POST",
114 | action="UpdateRecord",
115 | RecordId=old_record.get("RecordId"),
116 | Data={"Value": value},
117 | Ttl=ttl,
118 | **extra
119 | ) # fmt: skip
120 |
121 | if data and data.get("RecordId"):
122 | self.logger.info("Record updated: %s", data)
123 | return True
124 |
125 | self.logger.error("Failed to update record: %s", data)
126 | return False
127 |
128 | def _get_type(self, record_type):
129 | # type: (str) -> str
130 | return "A/AAAA" if record_type in ("A", "AAAA") else record_type
131 |
--------------------------------------------------------------------------------
/ddns/scheduler/launchd.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | macOS launchd-based task scheduler
4 | @author: NewFuture
5 | """
6 |
7 | import os
8 | import re
9 |
10 | from ..util.fileio import read_file_safely, write_file
11 | from ..util.try_run import try_run
12 | from ._base import BaseScheduler
13 |
14 |
15 | class LaunchdScheduler(BaseScheduler):
16 | """macOS launchd-based task scheduler"""
17 |
18 | LABEL = "cc.newfuture.ddns"
19 |
20 | def _get_plist_path(self):
21 | return os.path.expanduser("~/Library/LaunchAgents/{}.plist".format(self.LABEL))
22 |
23 | def is_installed(self):
24 | return os.path.exists(self._get_plist_path())
25 |
26 | def get_status(self):
27 | # Read plist content once and use it to determine installation status
28 | content = read_file_safely(self._get_plist_path())
29 | status = {"scheduler": "launchd", "installed": bool(content)}
30 | if not content:
31 | return status
32 |
33 | # For launchd, check if service is actually loaded/enabled
34 | result = try_run(["launchctl", "list"], logger=self.logger)
35 | status["enabled"] = bool(result) and self.LABEL in result
36 |
37 | # Get interval
38 | interval_match = re.search(r"StartInterval\s*(\d+)", content)
39 | status["interval"] = int(interval_match.group(1)) // 60 if interval_match else None
40 |
41 | # Get command
42 | program_match = re.search(r"Program\s*([^<]+)", content)
43 | if program_match:
44 | status["command"] = program_match.group(1)
45 | else:
46 | args_section = re.search(r"ProgramArguments\s*(.*?)", content, re.DOTALL)
47 | if args_section:
48 | strings = re.findall(r"([^<]+)", args_section.group(1))
49 | if strings:
50 | status["command"] = " ".join(strings)
51 |
52 | # Get comments
53 | desc_match = re.search(r"Description\s*([^<]+)", content)
54 | status["description"] = desc_match.group(1) if desc_match else None
55 |
56 | return status
57 |
58 | def install(self, interval, ddns_args=None):
59 | plist_path = self._get_plist_path()
60 | program_args = self._build_ddns_command(ddns_args)
61 |
62 | # Create comment with version and install date (consistent with Windows)
63 | comment = self._get_description()
64 |
65 | # Generate plist XML using template string for proper macOS plist format
66 | program_args_xml = "".join(" {}\n".format(arg) for arg in program_args)
67 |
68 | plist_content = """
69 |
70 |
71 | Label
72 | {label}
73 | Description
74 | {description}
75 | ProgramArguments
76 | {program}
77 | StartInterval
78 | {interval}
79 | RunAtLoad
80 |
81 | WorkingDirectory
82 | {dir}
83 |
84 | """.format(
85 | label=self.LABEL, description=comment, program=program_args_xml, interval=interval * 60, dir=os.getcwd()
86 | )
87 |
88 | write_file(plist_path, plist_content)
89 | result = try_run(["launchctl", "load", plist_path], logger=self.logger)
90 | if result is not None:
91 | self.logger.info("DDNS launchd service installed successfully")
92 | return True
93 | else:
94 | self.logger.error("Failed to load launchd service")
95 | return False
96 |
97 | def uninstall(self):
98 | plist_path = self._get_plist_path()
99 | try_run(["launchctl", "unload", plist_path], logger=self.logger) # Ignore errors
100 | try:
101 | os.remove(plist_path)
102 | except OSError:
103 | pass
104 |
105 | self.logger.info("DDNS launchd service uninstalled successfully")
106 | return True
107 |
108 | def enable(self):
109 | plist_path = self._get_plist_path()
110 | if not os.path.exists(plist_path):
111 | self.logger.error("DDNS launchd service not found")
112 | return False
113 |
114 | result = try_run(["launchctl", "load", plist_path], logger=self.logger)
115 | if result is not None:
116 | self.logger.info("DDNS launchd service enabled successfully")
117 | return True
118 | else:
119 | self.logger.error("Failed to enable launchd service")
120 | return False
121 |
122 | def disable(self):
123 | plist_path = self._get_plist_path()
124 | result = try_run(["launchctl", "unload", plist_path], logger=self.logger)
125 | if result is not None:
126 | self.logger.info("DDNS launchd service disabled successfully")
127 | return True
128 | else:
129 | self.logger.error("Failed to disable launchd service")
130 | return False
131 |
--------------------------------------------------------------------------------
/doc/providers/dnspod_com.md:
--------------------------------------------------------------------------------
1 | # DNSPod 国际版 配置指南
2 |
3 | **ℹ️ 版本区别**:
4 |
5 | - 本文档适用于 DNSPod 国际版(dnspod.com)
6 | - 中国版(dnspod.cn)请参阅 [DNSPod 中国版配置指南](dnspod.md)
7 |
8 | ## 概述
9 |
10 | DNSPod 国际版(dnspod.com)是面向全球用户的权威 DNS 解析服务,在海外地区广泛使用,支持动态 DNS 记录的创建与更新。本 DDNS 项目支持多种认证方式连接 DNSPod 国际版进行动态 DNS 记录管理。
11 |
12 | 官网链接:
13 |
14 | - 官方网站:
15 |
16 | ## 认证信息
17 |
18 | ### 1. API Token 认证(推荐)
19 |
20 | API Token 方式更安全,是 DNSPod 推荐的集成方法。
21 |
22 | #### 获取认证信息
23 |
24 | 1. 登录 [DNSPod 国际版控制台](https://www.dnspod.com/)
25 | 2. 进入"User Center" > "API Token"或访问
26 | 3. 点击"Create Token",填写描述,选择域名管理权限,完成创建
27 | 4. 复制 **ID**(数字)和 **Token**(字符串),密钥只显示一次,请妥善保存
28 |
29 | ```json
30 | {
31 | "dns": "dnspod_com",
32 | "id": "123456", // DNSPod International API Token ID
33 | "token": "YOUR_API_TOKEN" // DNSPod International API Token Secret
34 | }
35 | ```
36 |
37 | ### 2. 邮箱密码认证(不推荐)
38 |
39 | 使用 DNSPod 账号邮箱和密码,安全性较低,仅建议特殊场景使用。
40 |
41 | ```json
42 | {
43 | "dns": "dnspod_com",
44 | "id": "your-email@example.com", // DNSPod 账号邮箱
45 | "token": "your-account-password" // DNSPod 账号密码
46 | }
47 | ```
48 |
49 | ## 完整配置示例
50 |
51 | ```json
52 | {
53 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // 格式验证
54 | "dns": "dnspod_com", // 当前服务商
55 | "id": "123456", // DNSPod 国际版 API Token ID
56 | "token": "YOUR_API_TOKEN", // DNSPod 国际版 API Token Secret
57 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
58 | "index6": "public", // IPv6地址来源
59 | "ipv4": ["ddns.newfuture.cc"], // IPv4 域名
60 | "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"], // IPv6 域名
61 | "line": "default", // 解析线路
62 | "ttl": 600 // DNS记录TTL(秒)
63 | }
64 | ```
65 |
66 | ### 参数说明
67 |
68 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
69 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
70 | | dns | 服务商标识 | 字符串 | `dnspod_com` | 无 | 服务商参数 |
71 | | id | 认证 ID | 字符串 | DNSPod API Token ID 或邮箱 | 无 | 服务商参数 |
72 | | token | 认证密钥 | 字符串 | DNSPod API Token 密钥或密码 | 无 | 服务商参数 |
73 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
74 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
75 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
76 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
77 | | line | 解析线路 | 字符串 | [参考下方](#line) | `default` | 服务商参数 |
78 | | ttl | TTL 时间 | 整数(秒) | [参考下方](#ttl) | `600` | 服务商参数 |
79 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
80 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
81 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
82 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
83 |
84 | > **参数类型说明**:
85 | >
86 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
87 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数
88 | > - **服务商参数**:当前服务商支持,值与当前服务商相关
89 | >
90 | > **注意**:`ttl` 和 `line` 不同套餐支持的值可能不同。
91 |
92 | ### ttl
93 |
94 | `ttl` 参数指定 DNS 记录的生存时间(TTL),单位为秒。DNSPod 国际版支持的 TTL 范围为 1 到 604800 秒(即 7 天)。如果不设置,则使用默认值。
95 |
96 | | 套餐类型 | 支持的 TTL 范围(秒) |
97 | | :------ | :------------------- |
98 | | 免费版 | 600 ~ 604800 |
99 | | 专业版 | 120 ~ 604800 |
100 | | 企业版 | 60 ~ 604800 |
101 | | 尊享版 | 1 ~ 604800 |
102 |
103 | > 参考:[DNSPod International TTL 说明](https://docs.dnspod.com/dns/help-ttl)
104 |
105 | ### line
106 |
107 | `line` 参数指定 DNS 解析线路,DNSPod 国际版支持的线路(使用英文标识):
108 |
109 | | 线路标识 | 说明 |
110 | | :---------- | :----------- |
111 | | Default | 默认 |
112 | | China Telecom | 中国电信 |
113 | | China Unicom | 中国联通 |
114 | | China Mobile | 中国移动 |
115 | | CERNET | 中国教育网 |
116 | | Chinese mainland | 中国大陆 |
117 | | Search engine | 搜索引擎 |
118 |
119 | > 更多线路参考:[DNSPod International 解析线路说明](https://docs.dnspod.com/dns/help-line)
120 |
121 | ## 故障排除
122 |
123 | ### 调试模式
124 |
125 | 启用调试日志查看详细信息:
126 |
127 | ```sh
128 | ddns -c config.json --debug
129 | ```
130 |
131 | ### 常见问题
132 |
133 | - **认证失败**:检查 API Token 或邮箱/密码是否正确,确认有域名管理权限
134 | - **域名未找到**:确保域名已添加到 DNSPod 国际版账号,配置拼写无误,域名处于活跃状态
135 | - **记录创建失败**:检查子域名是否有冲突记录,TTL 设置合理,确认有修改权限
136 | - **请求频率限制**:DNSPod 有 API 调用频率限制,降低请求频率
137 | - **地区访问限制**:DNSPod 国际版在某些地区可能有访问限制
138 |
139 | ## 支持与资源
140 |
141 | - [DNSPod 国际版产品文档](https://www.dnspod.com/docs/)
142 | - [DNSPod 国际版 API 参考](https://www.dnspod.com/docs/index.html)
143 | - [DNSPod 国际版控制台](https://www.dnspod.com/)
144 | - [DNSPod 中国版配置指南](./dnspod.md)
145 |
146 | > **建议**:推荐使用 API Token 方式,提升安全性与管理便捷性,避免使用邮箱密码方式。对于中国大陆用户,建议使用 [DNSPod 中国版](./dnspod.md)。
147 |
--------------------------------------------------------------------------------
/doc/providers/edgeone_dns.md:
--------------------------------------------------------------------------------
1 | # 腾讯云 EdgeOne DNS 配置指南
2 |
3 | ## 概述
4 |
5 | 腾讯云 EdgeOne DNS 提供商用于管理非加速域名的 DNS 记录。当您的域名完全托管给 EdgeOne 后,除了管理加速域名外,还可以使用此提供商来管理普通的 DNS 记录。
6 |
7 | > **与 EdgeOne 加速域名的区别**:
8 | >
9 | > - **EdgeOne (加速域名)**: 用于管理边缘加速域名的源站 IP 地址,主要用于 CDN 加速场景。使用 `edgeone`、`edgeone_acc`、`teo` 或 `teo_acc` 作为 dns 参数值。
10 | > - **EdgeOne DNS (非加速域名)**: 用于管理普通 DNS 记录,类似于传统 DNS 解析服务。使用 `edgeone_dns` 或 `edgeone_noacc` 作为 dns 参数值。
11 |
12 | 官网链接:
13 |
14 | - 官方网站:
15 | - EdgeOne 国际版:
16 | - 服务商控制台:
17 |
18 | ## 认证信息
19 |
20 | ### SecretId/SecretKey 认证
21 |
22 | 使用腾讯云 SecretId 和 SecretKey 进行认证,与腾讯云 DNS 使用相同的认证方式。
23 |
24 | > 与[腾讯云 DNS](tencentcloud.md) 相同,EdgeOne 使用 SecretId 和 SecretKey 进行认证。但是权限要求不同,需要确保账号具有 EdgeOne 的操作权限。
25 |
26 | #### 获取认证信息
27 |
28 | 1. 登录 [腾讯云控制台](https://console.cloud.tencent.com/)
29 | 2. 访问 [API密钥管理](https://console.cloud.tencent.com/cam/capi)
30 | 3. 点击"新建密钥"按钮
31 | 4. 复制生成的 **SecretId** 和 **SecretKey**,请妥善保存
32 | 5. 确保账号具有 EdgeOne 的操作权限
33 |
34 | ```jsonc
35 | {
36 | "dns": "edgeone_dns", // 使用 EdgeOne DNS 提供商
37 | "id": "SecretId", // 腾讯云 SecretId
38 | "token": "SecretKey" // 腾讯云 SecretKey
39 | }
40 | ```
41 |
42 | ## 权限要求
43 |
44 | 确保使用的腾讯云账号具有以下权限:
45 |
46 | - **QcloudTEOFullAccess**:EdgeOne 完全访问权限(推荐)
47 | - **QcloudTEOReadOnlyAccess + 自定义写权限**:精细化权限控制
48 |
49 | 可以在 [访问管理](https://console.cloud.tencent.com/cam/policy) 中查看和配置权限。
50 |
51 | ## 完整配置示例
52 |
53 | ```jsonc
54 | {
55 | "$schema": "https://ddns.newfuture.cc/schema/v4.1.json", // 格式验证
56 | "dns": "edgeone_dns", // EdgeOne DNS 提供商(非加速域名)
57 | "id": "your_secret_id", // 腾讯云 SecretId
58 | "token": "your_secret_key", // 腾讯云 SecretKey
59 | "index4": ["url:http://api.ipify.cn", "public"], // IPv4地址来源
60 | "index6": "public", // IPv6地址来源
61 | "ipv4": ["ddns.newfuture.cc"], // IPv4 域名
62 | "ipv6": ["ipv6.ddns.newfuture.cc"], // IPv6 域名
63 | "endpoint": "https://teo.tencentcloudapi.com" // API端点
64 | }
65 | ```
66 |
67 | ### 参数说明
68 |
69 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
70 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
71 | | dns | 服务商标识 | 字符串 | `edgeone_dns`, `teo_dns`, `edgeone_noacc` | 无 | 服务商参数 |
72 | | id | 认证 ID | 字符串 | 腾讯云 SecretId | 无 | 服务商参数 |
73 | | token | 认证密钥 | 字符串 | 腾讯云 SecretKey | 无 | 服务商参数 |
74 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
75 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
76 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
77 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
78 | | endpoint| API 端点 | URL | [参考下方](#endpoint) | `https://teo.tencentcloudapi.com` | 服务商参数 |
79 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
80 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
81 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
82 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
83 |
84 | > **参数类型说明**:
85 | >
86 | > - **公用配置**:所有支持的DNS服务商均适用的标准DNS配置参数
87 | > - **公用网络**:所有支持的DNS服务商均适用的网络设置参数
88 | > - **服务商参数**:当前服务商支持,值与当前服务商相关
89 |
90 | ### endpoint
91 |
92 | 腾讯云 EdgeOne 支持国内和国际版API端点,可根据区域和账号类型选择:
93 |
94 | #### 国内版
95 |
96 | - **默认(推荐)**:`https://teo.tencentcloudapi.com`
97 |
98 | #### 国际版
99 |
100 | - **国际版**:`https://teo.intl.tencentcloudapi.com`
101 |
102 | > **注意**:请根据您的腾讯云账号类型选择对应的端点。国内账号使用国内版端点,国际账号使用国际版端点。如果不确定,建议使用默认的国内版端点。
103 |
104 | ## DNS 提供商对比
105 |
106 | | 提供商标识 | 用途 | API 操作 | 适用场景 |
107 | | :--------: | :--- | :------- | :------- |
108 | | `edgeone`、`edgeone_acc`、`teo_acc` | 加速域名 | `CreateAccelerationDomain`, `ModifyAccelerationDomain`, `DescribeAccelerationDomains` | CDN 边缘加速,更新源站 IP |
109 | | `edgeone_dns`、`teo_dns`、`edgeone_noacc` | DNS 记录 | `CreateDnsRecord`, `ModifyDnsRecords`, `DescribeDnsRecords` | 普通 DNS 解析服务 |
110 |
111 | ## 故障排除
112 |
113 | ### 调试模式
114 |
115 | 启用调试日志查看详细信息:
116 |
117 | ```sh
118 | ddns -c config.json --debug
119 | ```
120 |
121 | ### 常见问题
122 |
123 | - **认证失败**:检查 SecretId 和 SecretKey 是否正确,确认账号权限
124 | - **站点未找到**:确保域名已添加到 EdgeOne 站点,域名状态正常
125 | - **DNS 记录不存在**:确认域名已在 EdgeOne 中正确托管
126 | - **权限不足**:确保账号具有 EdgeOne 的管理权限
127 |
128 | ## 支持与资源
129 |
130 | - [腾讯云 EdgeOne 产品文档](https://cloud.tencent.com/document/product/1552)
131 | - [EdgeOne API 文档](https://cloud.tencent.com/document/api/1552)
132 | - [EdgeOne DNS 记录 API](https://cloud.tencent.com/document/api/1552/86336)
133 | - [EdgeOne 控制台](https://console.cloud.tencent.com/edgeone)
134 | - [腾讯云技术支持](https://cloud.tencent.com/document/product/282)
135 |
136 | > **提示**:如需使用 EdgeOne 的边缘加速功能,请使用 [EdgeOne 加速域名提供商](./edgeone.md)。如需传统 DNS 解析服务且不使用 EdgeOne,建议使用 [腾讯云 DNS](./tencentcloud.md)。
137 |
--------------------------------------------------------------------------------
/tests/scripts/test-task-windows.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | REM Windows Task Management Test Script
3 | REM Tests DDNS task functionality on Windows systems
4 | REM Exits with non-zero status on verification failure
5 | REM Usage: test-task-windows.bat [DDNS_COMMAND]
6 | REM Examples:
7 | REM test-task-windows.bat (uses default: python3 -m ddns)
8 | REM test-task-windows.bat ddns (uses ddns command)
9 | REM test-task-windows.bat ./dist/ddns.exe (uses binary executable)
10 | REM test-task-windows.bat "python -m ddns" (uses custom python command)
11 |
12 | setlocal enabledelayedexpansion
13 | set "PYTHON_CMD=python3"
14 |
15 | REM Check if DDNS command is provided as argument
16 | if "%~1"=="" (
17 | set "DDNS_CMD=%PYTHON_CMD% -m ddns"
18 | ) else (
19 | set "DDNS_CMD=%~1"
20 | )
21 |
22 | echo === DDNS Task Management Test for Windows ===
23 | echo DDNS Command: %DDNS_CMD%
24 | echo.
25 |
26 | REM Check if we're actually on Windows
27 | ver | findstr /i "Windows" >nul
28 | if !ERRORLEVEL! neq 0 (
29 | echo ERROR: This script is designed for Windows
30 | exit /b 1
31 | )
32 |
33 | for /f "tokens=*" %%i in ('ver') do echo Confirmed running on %%i
34 |
35 | REM Test Step 1: Initial state check
36 | echo.
37 | echo === Step 1: Initial state verification ===
38 | %DDNS_CMD% task -h
39 | if !ERRORLEVEL! neq 0 (
40 | echo ERROR: Task help command failed
41 | exit /b 1
42 | )
43 |
44 | %DDNS_CMD% task --status
45 | for /f "tokens=*" %%i in ('%DDNS_CMD% task --status ^| findstr "Installed:"') do set "initial_status=%%i"
46 | if not defined initial_status set "initial_status=Installed: Unknown"
47 | echo Initial status: !initial_status!
48 |
49 | REM Check initial system state - should not exist
50 | echo.
51 | echo === Step 2: Initial system state verification ===
52 | echo Checking Windows Task Scheduler...
53 | schtasks /query /tn "DDNS" >nul 2>&1
54 | if !ERRORLEVEL! == 0 (
55 | echo ERROR: DDNS scheduled task should not exist initially but was found
56 | exit /b 1
57 | ) else (
58 | echo OK: No DDNS scheduled task found initially
59 | )
60 |
61 | REM Test Step 3: Install task
62 | echo.
63 | echo === Step 3: Installing DDNS task ===
64 | echo !initial_status! | findstr /i "Installed.*No" >nul
65 | if !ERRORLEVEL! == 0 (
66 | echo Installing task with 12-minute interval...
67 | %DDNS_CMD% task --install 12
68 | if !ERRORLEVEL! neq 0 (
69 | echo ERROR: Task installation failed
70 | exit /b 1
71 | )
72 | echo OK: Task installation command completed
73 | ) else (
74 | echo Task already installed, proceeding with verification...
75 | )
76 |
77 | REM Test Step 4: Verify installation
78 | echo.
79 | echo === Step 4: Verifying installation ===
80 | for /f "tokens=*" %%i in ('%DDNS_CMD% task --status ^| findstr "Installed:"') do set "install_status=%%i"
81 | echo Status: !install_status!
82 |
83 | echo !install_status! | findstr /i "Installed.*Yes" >nul
84 | if !ERRORLEVEL! == 0 (
85 | echo OK: DDNS status verification passed
86 | ) else (
87 | echo ERROR: Expected 'Installed: Yes', got '!install_status!'
88 | exit /b 1
89 | )
90 |
91 | REM Check system state after installation
92 | echo.
93 | echo === Step 5: System verification after installation ===
94 | echo Checking Windows Task Scheduler...
95 | schtasks /query /tn "DDNS" >nul 2>&1
96 | if !ERRORLEVEL! == 0 (
97 | echo OK: DDNS scheduled task found
98 | echo Task details:
99 | schtasks /query /tn "DDNS" /fo list 2>nul | findstr /i "TaskName State"
100 | ) else (
101 | echo ERROR: Scheduled task should exist but was not found
102 | exit /b 1
103 | )
104 |
105 | REM Test Step 6: Delete task
106 | echo.
107 | echo === Step 6: Deleting DDNS task ===
108 | %DDNS_CMD% task --uninstall
109 | if !ERRORLEVEL! neq 0 (
110 | echo ERROR: Task deletion failed
111 | exit /b 1
112 | )
113 | echo OK: Task deletion command completed
114 |
115 | REM Test Step 7: Verify deletion
116 | echo.
117 | echo === Step 7: Verifying deletion ===
118 | for /f "tokens=*" %%i in ('%DDNS_CMD% task --status ^| findstr "Installed:"') do set "final_status=%%i"
119 | echo Status: !final_status!
120 |
121 | echo !final_status! | findstr /i "Installed.*No" >nul
122 | if !ERRORLEVEL! == 0 (
123 | echo OK: DDNS status verification passed
124 | ) else (
125 | echo ERROR: Expected 'Installed: No', got '!final_status!'
126 | exit /b 1
127 | )
128 |
129 | REM Final system state verification
130 | echo.
131 | echo === Step 8: Final system state verification ===
132 | echo Checking Windows Task Scheduler...
133 | schtasks /query /tn "DDNS" >nul 2>&1
134 | if !ERRORLEVEL! == 0 (
135 | echo ERROR: Scheduled task should not exist but was found
136 | exit /b 1
137 | ) else (
138 | echo OK: DDNS scheduled task successfully removed
139 | )
140 |
141 | REM Test help commands availability
142 | echo.
143 | echo === Step 9: Help commands verification ===
144 | %DDNS_CMD% task --help | findstr /i "install uninstall enable disable status" >nul
145 | if !ERRORLEVEL! == 0 (
146 | echo OK: Task commands found in help
147 | ) else (
148 | echo ERROR: Task commands missing from help
149 | exit /b 1
150 | )
151 |
152 | echo.
153 | echo ===============================================
154 | echo ALL TESTS PASSED - Windows task management OK
155 | echo ===============================================
156 | echo.
157 |
158 | exit /b 0
159 |
--------------------------------------------------------------------------------
/tests/test_config_cli_extra.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | Unit tests for CLI extra field support in ddns.config.cli module
4 | @author: GitHub Copilot
5 | """
6 |
7 | import sys
8 | from __init__ import unittest
9 | from ddns.config.cli import load_config # noqa: E402
10 |
11 |
12 | class TestCliExtraFields(unittest.TestCase):
13 | """Test CLI extra field parsing"""
14 |
15 | def setUp(self):
16 | """Save original sys.argv"""
17 | self.original_argv = sys.argv
18 |
19 | def tearDown(self):
20 | """Restore original sys.argv"""
21 | sys.argv = self.original_argv
22 |
23 | def test_cli_extra_single_field(self):
24 | """Test single --extra.xxx argument"""
25 | sys.argv = ["ddns", "--dns", "cloudflare", "--extra.proxied", "true"]
26 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
27 | self.assertEqual(config.get("dns"), "cloudflare")
28 | self.assertEqual(config.get("extra_proxied"), "true")
29 |
30 | def test_cli_extra_multiple_fields(self):
31 | """Test multiple --extra.xxx arguments"""
32 | sys.argv = [
33 | "ddns",
34 | "--dns",
35 | "cloudflare",
36 | "--extra.proxied",
37 | "true",
38 | "--extra.comment",
39 | "Test comment",
40 | "--extra.priority",
41 | "10",
42 | ]
43 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
44 | self.assertEqual(config.get("dns"), "cloudflare")
45 | self.assertEqual(config.get("extra_proxied"), "true")
46 | self.assertEqual(config.get("extra_comment"), "Test comment")
47 | self.assertEqual(config.get("extra_priority"), "10")
48 |
49 | def test_cli_extra_with_standard_args(self):
50 | """Test --extra.xxx mixed with standard arguments"""
51 | sys.argv = [
52 | "ddns",
53 | "--dns",
54 | "alidns",
55 | "--id",
56 | "test@example.com",
57 | "--token",
58 | "secret123",
59 | "--extra.custom_field",
60 | "custom_value",
61 | "--ttl",
62 | "300",
63 | ]
64 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
65 | self.assertEqual(config.get("dns"), "alidns")
66 | self.assertEqual(config.get("id"), "test@example.com")
67 | self.assertEqual(config.get("token"), "secret123")
68 | self.assertEqual(config.get("ttl"), 300)
69 | self.assertEqual(config.get("extra_custom_field"), "custom_value")
70 |
71 | def test_cli_extra_flag_without_value(self):
72 | """Test --extra.xxx without a value (should be treated as True)"""
73 | sys.argv = ["ddns", "--dns", "cloudflare", "--extra.enabled"]
74 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
75 | self.assertEqual(config.get("dns"), "cloudflare")
76 | self.assertTrue(config.get("extra_enabled"))
77 |
78 | def test_cli_extra_with_dots_in_name(self):
79 | """Test --extra.xxx.yyy format (nested key)"""
80 | sys.argv = ["ddns", "--dns", "cloudflare", "--extra.settings.key1", "value1"]
81 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
82 | self.assertEqual(config.get("dns"), "cloudflare")
83 | # The key should be settings.key1 (not nested object)
84 | self.assertEqual(config.get("extra_settings.key1"), "value1")
85 |
86 | def test_cli_extra_empty_value(self):
87 | """Test --extra.xxx with empty string value"""
88 | sys.argv = ["ddns", "--dns", "cloudflare", "--extra.comment", ""]
89 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
90 | self.assertEqual(config.get("dns"), "cloudflare")
91 | self.assertEqual(config.get("extra_comment"), "")
92 |
93 | def test_cli_extra_numeric_values(self):
94 | """Test --extra.xxx with numeric string values"""
95 | sys.argv = ["ddns", "--dns", "cloudflare", "--extra.priority", "100", "--extra.weight", "0.5"]
96 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
97 | self.assertEqual(config.get("extra_priority"), "100")
98 | self.assertEqual(config.get("extra_weight"), "0.5")
99 |
100 | def test_cli_extra_special_characters(self):
101 | """Test --extra.xxx with special characters in value"""
102 | sys.argv = ["ddns", "--dns", "cloudflare", "--extra.url", "https://example.com/path?key=value"]
103 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
104 | self.assertEqual(config.get("extra_url"), "https://example.com/path?key=value")
105 |
106 | def test_cli_no_extra_args(self):
107 | """Test that config works without any extra arguments"""
108 | sys.argv = ["ddns", "--dns", "cloudflare", "--id", "test@example.com"]
109 | config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
110 | self.assertEqual(config.get("dns"), "cloudflare")
111 | self.assertEqual(config.get("id"), "test@example.com")
112 | # No extra_* keys should exist
113 | extra_keys = [k for k in config.keys() if k.startswith("extra_")]
114 | self.assertEqual(len(extra_keys), 0)
115 |
116 |
117 | if __name__ == "__main__":
118 | unittest.main()
119 |
--------------------------------------------------------------------------------
/doc/providers/callback.md:
--------------------------------------------------------------------------------
1 | # Callback Provider 配置指南
2 |
3 | Callback Provider 是一个通用的自定义回调接口,允许您将 DDNS 更新请求转发到任何自定义的 HTTP API 端点或者webhook。这个 Provider 非常灵活,支持 GET 和 POST 请求,并提供变量替换功能。
4 |
5 | ## 基本配置
6 |
7 | | 参数 | 说明 | 必填 | 示例 |
8 | |------|------|------|------|
9 | | `id` | 回调URL地址,支持变量替换 | - | `https://api.example.com/ddns?domain=__DOMAIN__&ip=__IP__` |
10 | | `token` | POST 请求参数(JSON对象或JSON字符串),为空时使用GET请求 | 可选 | `{"api_key": "your_key"}` 或 `"{\"api_key\": \"your_key\"}"` |
11 | | `endpoint` | 可选,API端点地址,不会参与变量替换 | - | `https://api.example.com/ddns` |
12 | | `dns` | 固定值 `"callback"`,表示使用回调方式 | ✅ | `"callback"` |
13 |
14 | ## 完整配置示例
15 |
16 | ```json
17 | {
18 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
19 | "dns": "callback",
20 | "endpoint": "https://api.example.com", // endpoint 可以和 Id 参数合并
21 | "id": "/ddns?domain=__DOMAIN__&ip=__IP__", // endpoint 可以和 Id 不能同时为空
22 | "token": "", // 空字符串表示使用 GET 请求, 有值时使用 POST 请求
23 | "index4": ["url:http://api.ipify.cn", "public"],
24 | "index6": "public",
25 | "ipv4": "ddns.newfuture.cc",
26 | "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"]
27 | }
28 | ```
29 |
30 | ### 参数说明
31 |
32 | | 参数 | 说明 | 类型 | 取值范围/选项 | 默认值 | 参数类型 |
33 | | :-----: | :----------- | :------------- | :--------------------------------- | :-------- | :--------- |
34 | | index4 | IPv4 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
35 | | index6 | IPv6 来源 | 数组 | [参考配置](../config/json.md#ipv4-ipv6) | `default` | 公用配置 |
36 | | ipv4 | IPv4 域名 | 数组 | 域名列表 | 无 | 公用配置 |
37 | | ipv6 | IPv6 域名 | 数组 | 域名列表 | 无 | 公用配置 |
38 | | proxy | 代理设置 | 数组 | [参考配置](../config/json.md#proxy) | 无 | 公用网络 |
39 | | ssl | SSL 验证方式 | 布尔/字符串 | `"auto"`、`true`、`false` | `auto` | 公用网络 |
40 | | cache | 缓存设置 | 布尔/字符串 | `true`、`false`、`filepath` | `true` | 公用配置 |
41 | | log | 日志配置 | 对象 | [参考配置](../config/json.md#log) | 无 | 公用配置 |
42 |
43 | ## 请求方式
44 |
45 | | 方法 | 条件 | 描述 |
46 | |------|------------|--------------------|
47 | | GET | token 为空 | 使用 URL 查询参数 |
48 | | POST | token 非空 | 使用 JSON 请求体 |
49 |
50 | ### GET 请求示例
51 |
52 | ```json
53 | {
54 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
55 | "dns": "callback",
56 | "id": "https://api.example.com/update?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__",
57 | "index4": ["url:http://api.ipify.cn", "public"],
58 | "ipv4": "ddns.newfuture.cc",
59 | }
60 | ```
61 |
62 | ```http
63 | GET https://api.example.com/update?domain=ddns.newfuture.cc&ip=192.168.1.100&type=A
64 | ```
65 |
66 | ### POST 请求示例
67 |
68 | ```json
69 | {
70 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
71 | "dns": "callback",
72 | "endpoint": "https://api.example.com",
73 | "token": {
74 | "api_key": "your_secret_key",
75 | "domain": "__DOMAIN__",
76 | "value": "__IP__"
77 | },
78 | "index4": ["url:http://api.ipify.cn", "public"],
79 | "ipv4": "ddns.newfuture.cc",
80 | }
81 | ```http
82 | POST https://api.example.com
83 | Content-Type: application/json
84 |
85 | {
86 | "api_key": "your_secret_key",
87 | "domain": "ddns.newfuture.cc",
88 | "value": "192.168.1.100",
89 | }
90 | ```
91 |
92 | ## 变量替换
93 |
94 | Callback Provider 支持以下内置变量,在请求时会自动替换:
95 |
96 | | 变量 | 说明 | 示例值 |
97 | |------|------|--------|
98 | | `__DOMAIN__` | 完整域名 | `sub.example.com` |
99 | | `__IP__` | IP地址(IPv4或IPv6) | `192.168.1.100` 或 `2001:db8::1` |
100 | | `__RECORDTYPE__` | DNS记录类型 | `A`、`AAAA`、`CNAME` |
101 | | `__TTL__` | 生存时间(秒) | `300`、`600` |
102 | | `__LINE__` | 解析线路 | `default`、`unicom` |
103 | | `__TIMESTAMP__` | 当前时间戳 | `1634567890.123` |
104 |
105 | ## 使用场景
106 |
107 | ### 1. 自定义 Webhook
108 |
109 | 将 DDNS 更新通知发送到自定义 webhook:
110 |
111 | ```json
112 | {
113 | "endpoint": "https://hooks.example.com",
114 | "id":"/webhook",
115 | "token": {
116 | "event": "ddns_update",
117 | "domain": "__DOMAIN__",
118 | "new_ip": "__IP__",
119 | "record_type": "__RECORDTYPE__",
120 | "timestamp": "__TIMESTAMP__"
121 | },
122 | "dns": "callback",
123 | "index4": ["default"]
124 | }
125 | ```
126 |
127 | ### 2. 使用字符串格式的 token
128 |
129 | 当需要动态构造复杂的 JSON 字符串时:
130 |
131 | ```json
132 | {
133 | "id": "https://api.example.com/ddns",
134 | "token": "{\"auth\": \"your_key\", \"record\": {\"name\": \"__DOMAIN__\", \"value\": \"__IP__\", \"type\": \"__RECORDTYPE__\"}}",
135 | "dns": "callback"
136 | }
137 | ```
138 |
139 | ## 故障排除
140 |
141 | ### 调试方法
142 |
143 | 1. **启用调试**: 在配置中设置 `"debug": true`
144 | 2. **查看日志**: 检查DDNS运行日志中的详细信息
145 | 3. **测试API**: 使用curl或Postman测试回调API
146 | 4. **网络检查**: 确保网络连通性和DNS解析正常
147 |
148 | ### 测试工具
149 |
150 | 可以使用在线工具测试回调功能:
151 |
152 | ```bash
153 | # 使用 curl 测试 GET 请求
154 | curl "https://httpbin.org/get?domain=test.example.com&ip=192.168.1.1"
155 |
156 | # 使用 curl 测试 POST 请求
157 | curl -X POST "https://httpbin.org/post" \
158 | -H "Content-Type: application/json" \
159 | -d '{"domain": "test.example.com", "ip": "192.168.1.1"}'
160 | ```
161 |
162 | ## 相关链接
163 |
164 | - [DDNS 项目首页](../../README.md)
165 | - [配置文件格式](../config/json.md)
166 | - [命令行使用](../config/cli.md)
167 | - [开发者指南](../dev/provider.md)
168 |
--------------------------------------------------------------------------------
/ddns/ip.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding:utf-8 -*-
3 | from re import compile
4 | from os import name as os_name, popen
5 | from socket import socket, getaddrinfo, gethostname, AF_INET, AF_INET6, SOCK_DGRAM
6 | from logging import debug, error
7 |
8 | from .util.http import request
9 |
10 | # 模块级别的SSL验证配置,默认使用auto模式
11 | ssl_verify = "auto"
12 |
13 | # IPV4正则
14 | IPV4_REG = r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])"
15 | # IPV6正则
16 | # https://community.helpsystems.com/forums/intermapper/miscellaneous-topics/5acc4fcf-fa83-e511-80cf-0050568460e4
17 | IPV6_REG = r"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))" # noqa: E501
18 |
19 | # 公网IPv4 API列表,按优先级排序
20 | PUBLIC_IPV4_APIS = [
21 | "https://api.ipify.cn",
22 | "https://api.ipify.org",
23 | "https://4.ipw.cn/",
24 | "https://ipinfo.io/ip",
25 | "https://api-ipv4.ip.sb/ip",
26 | "http://checkip.amazonaws.com",
27 | ]
28 |
29 | # 公网IPv6 API列表,按优先级排序
30 | PUBLIC_IPV6_APIS = [
31 | "https://api6.ipify.org/",
32 | "https://6.ipw.cn/",
33 | "https://api-ipv6.ip.sb/ip",
34 | "http://ipv6.icanhazip.com",
35 | ]
36 |
37 |
38 | def default_v4(): # 默认连接外网的ipv4
39 | s = socket(AF_INET, SOCK_DGRAM)
40 | s.connect(("1.1.1.1", 53))
41 | ip = s.getsockname()[0]
42 | s.close()
43 | return ip
44 |
45 |
46 | def default_v6(): # 默认连接外网的ipv6
47 | s = socket(AF_INET6, SOCK_DGRAM)
48 | s.connect(("1:1:1:1:1:1:1:1", 8))
49 | ip = s.getsockname()[0]
50 | s.close()
51 | return ip
52 |
53 |
54 | def local_v6(i=0): # 本地ipv6地址
55 | info = getaddrinfo(gethostname(), 0, AF_INET6)
56 | debug(info)
57 | return info[int(i)][4][0]
58 |
59 |
60 | def local_v4(i=0): # 本地ipv4地址
61 | info = getaddrinfo(gethostname(), 0, AF_INET)
62 | debug(info)
63 | return info[int(i)][-1][0]
64 |
65 |
66 | def _open(url, reg):
67 | try:
68 | debug("open: %s", url)
69 | # IP 模块重试3次
70 | response = request("GET", url, verify=ssl_verify, retries=2)
71 | res = response.body
72 | debug("response: %s", res)
73 | match = compile(reg).search(res)
74 | if match:
75 | return match.group()
76 | error("No match found in response: %s", res)
77 | except Exception as e:
78 | error(e)
79 |
80 |
81 | def _try_multiple_apis(api_list, reg, ip_type):
82 | """
83 | Try multiple API endpoints until one succeeds
84 | """
85 | for url in api_list:
86 | try:
87 | debug("Trying %s API: %s", ip_type, url)
88 | result = _open(url, reg)
89 | if result:
90 | debug("Successfully got %s from %s: %s", ip_type, url, result)
91 | return result
92 | else:
93 | debug("No valid IP found from %s", url)
94 | except Exception as e:
95 | debug("Failed to get %s from %s: %s", ip_type, url, e)
96 | return None
97 |
98 |
99 | def public_v4(url=None, reg=IPV4_REG): # 公网IPV4地址
100 | if url:
101 | # 使用指定URL
102 | return _open(url, reg)
103 | else:
104 | # 使用多个API自动重试
105 | return _try_multiple_apis(PUBLIC_IPV4_APIS, reg, "IPv4")
106 |
107 |
108 | def public_v6(url=None, reg=IPV6_REG): # 公网IPV6地址
109 | if url:
110 | # 使用指定URL
111 | return _open(url, reg)
112 | else:
113 | # 使用多个API自动重试
114 | return _try_multiple_apis(PUBLIC_IPV6_APIS, reg, "IPv6")
115 |
116 |
117 | def _ip_regex_match(parrent_regex, match_regex):
118 | ip_pattern = compile(parrent_regex)
119 | matcher = compile(match_regex)
120 |
121 | if os_name == "nt": # windows:
122 | cmd = "ipconfig"
123 | else:
124 | cmd = "ip address || ifconfig 2>/dev/null"
125 |
126 | for s in popen(cmd).readlines():
127 | addr = ip_pattern.search(s)
128 | if addr and matcher.match(addr.group(1)):
129 | return addr.group(1)
130 |
131 |
132 | def regex_v4(reg): # ipv4 正则提取
133 | if os_name == "nt": # Windows: IPv4 xxx: 192.168.1.2
134 | regex_str = r"IPv4 .*: ((?:\d{1,3}\.){3}\d{1,3})\W"
135 | else:
136 | regex_str = r"inet (?:addr\:)?((?:\d{1,3}\.){3}\d{1,3})[\s/]"
137 | return _ip_regex_match(regex_str, reg)
138 |
139 |
140 | def regex_v6(reg): # ipv6 正则提取
141 | if os_name == "nt": # Windows: IPv4 xxx: ::1
142 | regex_str = r"IPv6 .*: ([\:\dabcdef]*)?\W"
143 | else:
144 | regex_str = r"inet6 (?:addr\:\s*)?([\:\dabcdef]*)?[\s/%]"
145 | return _ip_regex_match(regex_str, reg)
146 |
--------------------------------------------------------------------------------
/tests/test_config_log_file_dir.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | # type: ignore[index,operator,assignment]
3 | """
4 | Unit tests for log file directory creation issue
5 | Reproduces the issue where log_file path has non-existent parent directories
6 | @author: GitHub Copilot
7 | """
8 |
9 | from __init__ import unittest
10 | import tempfile
11 | import json
12 | import os
13 | import sys
14 | import shutil
15 | from ddns.config import load_configs
16 |
17 |
18 | class TestLogFileDirectory(unittest.TestCase):
19 | """测试日志文件目录创建问题"""
20 |
21 | def setUp(self):
22 | self.temp_dir = tempfile.mkdtemp()
23 | self.original_argv = sys.argv[:]
24 |
25 | def tearDown(self):
26 | sys.argv = self.original_argv
27 | # Clean up logging handlers
28 | import logging
29 | for handler in logging.root.handlers[:]:
30 | logging.root.removeHandler(handler)
31 | # Clean up temp directory
32 | shutil.rmtree(self.temp_dir, ignore_errors=True)
33 |
34 | def test_log_file_with_nonexistent_directory_single_config(self):
35 | """测试单个配置时,log文件所在目录不存在的情况"""
36 | # 创建一个不存在的目录路径
37 | log_dir = os.path.join(self.temp_dir, "nonexistent", "subdir")
38 | log_file = os.path.join(log_dir, "ddns.log")
39 |
40 | # 确保目录不存在
41 | self.assertFalse(os.path.exists(log_dir))
42 |
43 | config_data = {
44 | "dns": "debug",
45 | "id": "test@example.com",
46 | "token": "secret123",
47 | "ipv4": ["test.example.com"],
48 | "log": {"file": log_file, "level": "INFO"},
49 | }
50 |
51 | config_path = os.path.join(self.temp_dir, "config.json")
52 | with open(config_path, "w") as f:
53 | json.dump(config_data, f)
54 |
55 | sys.argv = ["ddns", "-c", config_path]
56 |
57 | # 加载配置,应该自动创建目录
58 | configs = load_configs("test", "1.0", "2023-01-01")
59 |
60 | # 验证目录被创建
61 | self.assertTrue(os.path.exists(log_dir), "Log directory should be created")
62 | self.assertEqual(len(configs), 1)
63 | self.assertEqual(configs[0].log_file, log_file)
64 |
65 | def test_log_file_with_nonexistent_directory_multi_provider(self):
66 | """测试多个provider配置时,log文件所在目录不存在的情况(复现issue中的问题)"""
67 | # 创建一个不存在的目录路径
68 | log_dir = os.path.join(self.temp_dir, "ddns")
69 | log_file = os.path.join(log_dir, "ddns.log")
70 |
71 | # 确保目录不存在
72 | self.assertFalse(os.path.exists(log_dir))
73 |
74 | # 模拟issue中的配置:多个provider,共享同一个log文件
75 | config_data = {
76 | "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
77 | "ssl": "auto",
78 | "cache": os.path.join(self.temp_dir, "cache"),
79 | "log": {"level": "INFO", "file": log_file},
80 | "index4": "default",
81 | "index6": "default",
82 | "providers": [
83 | {"provider": "debug", "token": "token1", "ipv6": ["test1.xyz"]},
84 | {"provider": "debug", "token": "token2", "ipv6": ["test2.org"]},
85 | ],
86 | }
87 |
88 | config_path = os.path.join(self.temp_dir, "multi_config.json")
89 | with open(config_path, "w") as f:
90 | json.dump(config_data, f)
91 |
92 | sys.argv = ["ddns", "-c", config_path]
93 |
94 | # 加载配置,应该自动创建目录
95 | configs = load_configs("test", "1.0", "2023-01-01")
96 |
97 | # 验证目录被创建
98 | self.assertTrue(os.path.exists(log_dir), "Log directory should be created")
99 | self.assertEqual(len(configs), 2)
100 |
101 | # 验证两个配置都使用同一个log文件
102 | for config in configs:
103 | self.assertEqual(config.log_file, log_file)
104 |
105 | def test_log_file_with_nested_nonexistent_directory(self):
106 | """测试log文件路径有多级不存在的目录"""
107 | # 创建多级不存在的目录路径
108 | log_file = os.path.join(self.temp_dir, "a", "b", "c", "d", "ddns.log")
109 |
110 | config_data = {"dns": "debug", "id": "test@example.com", "token": "secret123", "log": {"file": log_file}}
111 |
112 | config_path = os.path.join(self.temp_dir, "config.json")
113 | with open(config_path, "w") as f:
114 | json.dump(config_data, f)
115 |
116 | sys.argv = ["ddns", "-c", config_path]
117 |
118 | # 加载配置,应该自动创建所有父目录
119 | configs = load_configs("test", "1.0", "2023-01-01")
120 |
121 | # 验证所有父目录被创建
122 | self.assertTrue(os.path.exists(os.path.dirname(log_file)), "All parent directories should be created")
123 | self.assertEqual(configs[0].log_file, log_file)
124 |
125 | def test_log_file_with_existing_directory(self):
126 | """测试log文件所在目录已存在的情况(不应该出错)"""
127 | # 创建目录
128 | log_dir = os.path.join(self.temp_dir, "existing_dir")
129 | os.makedirs(log_dir)
130 | log_file = os.path.join(log_dir, "ddns.log")
131 |
132 | config_data = {"dns": "debug", "id": "test@example.com", "token": "secret123", "log": {"file": log_file}}
133 |
134 | config_path = os.path.join(self.temp_dir, "config.json")
135 | with open(config_path, "w") as f:
136 | json.dump(config_data, f)
137 |
138 | sys.argv = ["ddns", "-c", config_path]
139 |
140 | # 加载配置,应该正常工作
141 | configs = load_configs("test", "1.0", "2023-01-01")
142 |
143 | self.assertTrue(os.path.exists(log_dir))
144 | self.assertEqual(configs[0].log_file, log_file)
145 |
146 |
147 | if __name__ == "__main__":
148 | unittest.main()
149 |
--------------------------------------------------------------------------------
/ddns/scheduler/systemd.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | """
3 | Systemd timer-based task scheduler for Linux
4 | @author: NewFuture
5 | """
6 |
7 | import os
8 | import re
9 |
10 | from ..util.fileio import read_file_safely, write_file
11 | from ..util.try_run import try_run
12 | from ._base import BaseScheduler
13 |
14 | try: # python 3
15 | PermissionError # type: ignore
16 | except NameError: # python 2 doesn't have PermissionError, use OSError instead
17 | PermissionError = IOError
18 |
19 |
20 | class SystemdScheduler(BaseScheduler):
21 | """Systemd timer-based task scheduler for Linux"""
22 |
23 | SERVICE_NAME = "ddns.service"
24 | TIMER_NAME = "ddns.timer"
25 | SERVICE_PATH = "/etc/systemd/system/ddns.service"
26 | TIMER_PATH = "/etc/systemd/system/ddns.timer"
27 |
28 | def _systemctl(self, *args):
29 | """Run systemctl command and return success status"""
30 | result = try_run(["systemctl"] + list(args), logger=self.logger)
31 | return result is not None
32 |
33 | def is_installed(self):
34 | """Check if systemd timer files exist"""
35 | return os.path.exists(self.SERVICE_PATH) and os.path.exists(self.TIMER_PATH)
36 |
37 | def get_status(self):
38 | """Get comprehensive status information"""
39 | installed = self.is_installed()
40 | status = {"scheduler": "systemd", "installed": installed}
41 | if not installed:
42 | return status
43 |
44 | # Check if timer is enabled
45 | result = try_run(["systemctl", "is-enabled", self.TIMER_NAME], logger=self.logger)
46 | status["enabled"] = bool(result and result.strip() == "enabled")
47 |
48 | # Extract interval from timer file
49 | timer_content = read_file_safely(self.TIMER_PATH) or ""
50 | match = re.search(r"OnUnitActiveSec=(\d+)m", timer_content)
51 | status["interval"] = int(match.group(1)) if match else None
52 |
53 | # Extract command and description from service file
54 | service_content = read_file_safely(self.SERVICE_PATH) or ""
55 | match = re.search(r"ExecStart=(.+)", service_content)
56 | status["command"] = match.group(1).strip() if match else None
57 | desc_match = re.search(r"Description=(.+)", service_content)
58 | status["description"] = desc_match.group(1).strip() if desc_match else None
59 |
60 | return status
61 |
62 | def install(self, interval, ddns_args=None):
63 | """Install systemd timer with specified interval"""
64 | ddns_commands = self._build_ddns_command(ddns_args)
65 | # Convert array to properly quoted command string for ExecStart
66 | ddns_command = self._quote_command_array(ddns_commands)
67 | work_dir = os.getcwd()
68 | description = self._get_description()
69 |
70 | # Create service file content
71 | service_content = u"""[Unit]
72 | Description={}
73 | After=network.target
74 |
75 | [Service]
76 | Type=oneshot
77 | WorkingDirectory={}
78 | ExecStart={}
79 | """.format(description, work_dir, ddns_command) # fmt: skip
80 |
81 | # Create timer file content
82 | timer_content = u"""[Unit]
83 | Description=DDNS automatic IP update timer
84 | Requires={}
85 |
86 | [Timer]
87 | OnUnitActiveSec={}m
88 | Unit={}
89 |
90 | [Install]
91 | WantedBy=multi-user.target
92 | """.format(self.SERVICE_NAME, interval, self.SERVICE_NAME) # fmt: skip
93 |
94 | try:
95 | # Write service and timer files
96 | write_file(self.SERVICE_PATH, service_content)
97 | write_file(self.TIMER_PATH, timer_content)
98 | except PermissionError as e:
99 | self.logger.debug("Permission denied when writing systemd files: %s", e)
100 | print("Permission denied. Please run as root or use sudo.")
101 | print("or use cron scheduler (with --scheduler=cron) instead.")
102 | return False
103 | except Exception as e:
104 | self.logger.error("Failed to write systemd files: %s", e)
105 | return False
106 |
107 | if self._systemctl("daemon-reload") and self._systemctl("enable", self.TIMER_NAME):
108 | self._systemctl("start", self.TIMER_NAME)
109 | return True
110 | else:
111 | self.logger.error("Failed to enable/start systemd timer")
112 | return False
113 |
114 | def uninstall(self):
115 | """Uninstall systemd timer and service"""
116 | self.disable() # Stop and disable timer
117 | # Remove systemd files
118 | try:
119 | os.remove(self.SERVICE_PATH)
120 | os.remove(self.TIMER_PATH)
121 | self._systemctl("daemon-reload") # Reload systemd configuration
122 | return True
123 |
124 | except PermissionError as e:
125 | self.logger.debug("Permission denied when removing systemd files: %s", e)
126 | print("Permission denied. Please run as root or use sudo.")
127 | return False
128 | except Exception as e:
129 | self.logger.error("Failed to remove systemd files: %s", e)
130 | return False
131 |
132 | def enable(self):
133 | """Enable and start systemd timer"""
134 | return self._systemctl("enable", self.TIMER_NAME) and self._systemctl("start", self.TIMER_NAME)
135 |
136 | def disable(self):
137 | """Disable and stop systemd timer"""
138 | self._systemctl("stop", self.TIMER_NAME)
139 | return self._systemctl("disable", self.TIMER_NAME)
140 |
--------------------------------------------------------------------------------
/ddns/provider/cloudflare.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | """
3 | CloudFlare API
4 | @author: TongYifan, NewFuture
5 | """
6 |
7 | from ._base import TYPE_JSON, BaseProvider, join_domain
8 |
9 |
10 | class CloudflareProvider(BaseProvider):
11 | endpoint = "https://api.cloudflare.com"
12 | content_type = TYPE_JSON
13 |
14 | def _validate(self):
15 | if not self.token:
16 | raise ValueError("token must be configured")
17 | if self.id:
18 | # must be email for Cloudflare API v4
19 | if "@" not in self.id:
20 | self.logger.critical("ID 必须为空或有效的邮箱地址")
21 | raise ValueError("ID must be a valid email or Empty for Cloudflare API v4")
22 |
23 | def _request(self, method, action, **params):
24 | """发送请求数据"""
25 | headers = {}
26 | if self.id:
27 | headers["X-Auth-Email"] = self.id
28 | headers["X-Auth-Key"] = self.token
29 | else:
30 | headers["Authorization"] = "Bearer " + self.token
31 |
32 | params = {k: v for k, v in params.items() if v is not None} # 过滤掉None参数
33 | data = self._http(method, "/client/v4/zones" + action, headers=headers, params=params)
34 | if data and data.get("success"):
35 | return data.get("result") # 返回结果或原始数据
36 | else:
37 | self.logger.warning("Cloudflare API error: %s", data.get("errors", "Unknown error"))
38 | return data
39 |
40 | def _query_zone_id(self, domain):
41 | """https://developers.cloudflare.com/api/resources/zones/methods/list/"""
42 | params = {"name.exact": domain, "per_page": 50}
43 | zones = self._request("GET", "", **params)
44 | zone = next((z for z in zones if domain == z.get("name", "")), None)
45 | self.logger.debug("Queried zone: %s", zone)
46 | if zone:
47 | return zone["id"]
48 | return None
49 |
50 | def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
51 | # type: (str, str, str, str, str | None, dict) -> dict | None
52 | """
53 | 查询DNS记录,优先使用extra filter匹配,匹配不到则fallback到不带extra的结果
54 |
55 | Query DNS records, prioritize extra filters, fallback to query without extra if no match found.
56 | https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/
57 | """
58 | # cloudflare的域名查询需要完整域名
59 | name = join_domain(subdomain, main_domain)
60 | query = {"name.exact": name} # type: dict[str, str|None]
61 |
62 | # 添加extra filter到查询参数,将布尔值转换为小写字符串
63 | proxied = extra.get("proxied") if extra else None
64 | if proxied is not None:
65 | query["proxied"] = str(proxied).lower() # True -> "true", False -> "false"
66 |
67 | # 先使用extra filter查询
68 | data = self._request("GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **query)
69 | record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
70 |
71 | # 如果使用了extra filter但没找到记录,尝试不带extra filter查询
72 | if not record and proxied is not None:
73 | self.logger.debug("No record found with extra filters, retrying without extra filters")
74 | data = self._request(
75 | "GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **{"name.exact": name}
76 | )
77 | record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
78 |
79 | self.logger.debug("Record queried: %s", record)
80 | if record:
81 | return record
82 | self.logger.warning("Failed to query record: %s", data)
83 | return None
84 |
85 | def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
86 | # type: (str, str, str, str, str, int | str | None, str | None, dict ) -> bool
87 | """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/"""
88 | name = join_domain(subdomain, main_domain)
89 | extra["comment"] = extra.get("comment", self.remark) # 添加注释
90 | data = self._request(
91 | "POST", "/{}/dns_records".format(zone_id), name=name, type=record_type, content=value, ttl=ttl, **extra
92 | )
93 | if data:
94 | self.logger.info("Record created: %s", data)
95 | return True
96 | self.logger.error("Failed to create record: %s", data)
97 | return False
98 |
99 | def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
100 | # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
101 | """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/"""
102 | extra["comment"] = extra.get("comment", self.remark) # 注释
103 | extra["proxied"] = extra.get("proxied", old_record.get("proxied")) # extra优先,保持原有的代理状态作为默认值
104 | extra["tags"] = extra.get("tags", old_record.get("tags")) # extra优先,保持原有的标签作为默认值
105 | extra["settings"] = extra.get("settings", old_record.get("settings")) # extra优先,保持原有的设置作为默认值
106 | data = self._request(
107 | "PUT",
108 | "/{}/dns_records/{}".format(zone_id, old_record["id"]),
109 | type=record_type,
110 | name=old_record.get("name"),
111 | content=value,
112 | ttl=ttl,
113 | **extra
114 | ) # fmt: skip
115 | self.logger.debug("Record updated: %s", data)
116 | if data:
117 | return True
118 | return False
119 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | #[build-system] #remove for python2
2 | #requires = ["setuptools>=64.0", "wheel", "setuptools_scm"]
3 | #build-backend = "setuptools.build_meta"
4 | #[tool.setuptools_scm]
5 | #local_scheme = "no-local-version"
6 |
7 | [project]
8 | name = "ddns"
9 | dynamic = ["version"]
10 | description = "Dynamic DNS client for multiple providers, supporting IPv4 and IPv6."
11 | authors = [{ name = "NewFuture", email = "python@newfuture.cc" }]
12 | readme = "README.md"
13 | license = "MIT"
14 | requires-python = ">=2.7"
15 | classifiers = [
16 | "Development Status :: 5 - Production/Stable",
17 | "Intended Audience :: Developers",
18 | "Intended Audience :: End Users/Desktop",
19 | "Intended Audience :: Information Technology",
20 | "Intended Audience :: System Administrators",
21 | "Topic :: Internet",
22 | "Topic :: Internet :: Name Service (DNS)",
23 | "Topic :: System :: Networking",
24 | "Topic :: Software Development",
25 | 'Programming Language :: Python :: 2.7',
26 | "Programming Language :: Python :: 3",
27 | "Programming Language :: Python :: 3.6",
28 | "Programming Language :: Python :: 3.7",
29 | "Programming Language :: Python :: 3.8",
30 | "Programming Language :: Python :: 3.9",
31 | "Programming Language :: Python :: 3.10",
32 | "Programming Language :: Python :: 3.11",
33 | "Programming Language :: Python :: 3.12",
34 | "Programming Language :: Python :: 3.13",
35 | ]
36 | keywords = ["ddns", "ipv6", "ipv4", "dns", "dnspod", "alidns", "cloudflare"]
37 | dependencies = []
38 |
39 | [project.urls]
40 | Homepage = "https://ddns.newfuture.cc"
41 | Documentation = "https://ddns.newfuture.cc"
42 | Repository = "https://github.com/NewFuture/DDNS"
43 | "Bug Tracker" = "https://github.com/NewFuture/DDNS/issues"
44 | Source = "https://github.com/NewFuture/DDNS"
45 |
46 | [project.scripts]
47 | ddns = "ddns.__main__:main"
48 |
49 | # Optional dependencies
50 | [project.optional-dependencies]
51 | dev = [
52 | "ruff",
53 | "mock;python_version<'3.3'", # For Python 2.7 compatibility
54 | ]
55 | # 可选的 pytest 支持(不是默认测试框架)
56 | pytest = [
57 | "pytest>=6.0",
58 | "pytest-cov",
59 | "mock;python_version<'3.3'",
60 | ]
61 |
62 | # Setuptools configuration
63 | [tool.setuptools]
64 | platforms = ["any"]
65 | packages = ["ddns", "ddns.config", "ddns.provider", "ddns.scheduler", "ddns.util"]
66 | package-dir= {"ddns" = "ddns"}
67 |
68 | #[tool.setuptools.packages.find]
69 | #where = ["."]
70 |
71 |
72 |
73 | [tool.setuptools.dynamic]
74 | version = { attr = "ddns.__version__" }
75 | # description = { attr = "ddns.__description__" }
76 |
77 |
78 | # 测试配置 - 使用 unittest 作为默认测试框架
79 | [tool.unittest]
80 | start-directory = "tests"
81 | pattern = "test_*.py"
82 | # unittest 不需要额外配置,使用内置的 test discovery
83 |
84 | # pytest 兼容配置(保持与 pytest 的兼容性)
85 | [tool.pytest.ini_options]
86 | testpaths = ["tests"]
87 | python_files = ["test_*.py"]
88 | python_classes = ["Test*"]
89 | python_functions = ["test_*"]
90 | addopts = ["-v", "--tb=short"]
91 | # 确保 pytest 可以找到 test_base 模块
92 | pythonpath = [".", "tests"]
93 |
94 | # Ruff configuration - unified formatting and linting
95 | [tool.ruff]
96 | # Same line length as black was using
97 | line-length = 120
98 | # Ruff minimum supported version is py37, but project supports py27+
99 | target-version = "py37"
100 |
101 | exclude = [
102 | ".eggs",
103 | ".git",
104 | ".hg",
105 | ".mypy_cache",
106 | ".tox",
107 | ".venv",
108 | "build",
109 | "dist",
110 | "__pycache__",
111 | "*.egg-info",
112 | ]
113 |
114 | [tool.ruff.lint]
115 | # Enable pycodestyle (E, W), pyflakes (F), and mccabe (C) - same as flake8 defaults
116 | # Deliberately exclude pyupgrade (UP) rules to maintain Python 2 compatibility
117 | # (UP rules would convert u"" strings to "" which breaks py2 compatibility)
118 | select = ["E", "W", "F", "C"]
119 | # Same ignores as flake8 configuration, but using ruff rule codes
120 | ignore = [
121 | # "E203", # whitespace before ':' - not needed in ruff, formatter handles this
122 | "E501", # line too long (handled by formatter)
123 | "UP025", # unicode-kind-prefix (keep u"..." for Py2 compatibility)
124 | ]
125 | # Same max complexity as flake8
126 | mccabe = { max-complexity = 12 }
127 |
128 | [tool.ruff.lint.per-file-ignores]
129 | # Allow unused imports and redefined names in tests (same as flake8)
130 | "tests/*" = ["F401", "F811"]
131 |
132 | [tool.ruff.format]
133 | line-ending = "auto"
134 | indent-style = "space"
135 | quote-style = "double"
136 | skip-magic-trailing-comma = true # py2
137 | docstring-code-format = true
138 | docstring-code-line-length = "dynamic"
139 |
140 | # 类型检查配置
141 | [tool.pyright]
142 | typeCheckingMode = "standard"
143 | autoImportCompletions = true
144 | autoFormatStrings = true
145 | completeFunctionParens = true
146 | supportAllPythonDocuments = true
147 | importFormat = "relative"
148 | generateWithTypeAnnotation = true
149 | diagnosticMode = "workspace"
150 | indexing = true
151 | useLibraryCodeForTypes = true
152 |
153 | # Coverage configuration (可选,仅在使用 pytest-cov 时需要)
154 | # 要使用覆盖率报告,需要安装: pip install pytest pytest-cov
155 | # 然后运行: pytest tests/ --cov=ddns --cov-report=term-missing
156 | [tool.coverage.run]
157 | source = ["ddns"]
158 | omit = [
159 | "*/tests/*",
160 | "*/test_*",
161 | "*/__pycache__/*",
162 | "*/.*",
163 | ]
164 |
165 | [tool.coverage.report]
166 | exclude_lines = [
167 | "pragma: no cover",
168 | "def __repr__",
169 | "if self.debug:",
170 | "if settings.DEBUG",
171 | "raise AssertionError",
172 | "raise NotImplementedError",
173 | "if 0:",
174 | "if __name__ == .__main__.:",
175 | "class .*\\bProtocol\\):",
176 | "@(abc\\.)?abstractmethod",
177 | ]
178 | show_missing = true
179 | precision = 2
180 |
--------------------------------------------------------------------------------
/doc/providers/README.md:
--------------------------------------------------------------------------------
1 | # DNS Provider 配置指南
2 |
3 | 本目录包含各个DNS服务商的详细配置指南。DDNS支持多个主流DNS服务商,每个服务商都有其特定的配置要求和API特性。
4 |
5 | ## 🚀 快速导航
6 |
7 | | Provider | 服务商 | 配置文档 | 英文文档 | 特点 |
8 | |----------|--------|----------|----------|------|
9 | | `alidns` | [阿里云DNS](https://dns.console.aliyun.com/) | [alidns 中文文档](alidns.md) | [alidns English Doc](alidns.en.md) | 阿里云生态集成 |
10 | | `aliesa` | [阿里云ESA](https://esa.console.aliyun.com/) | [aliesa 中文文档](aliesa.md) | [aliesa English Doc](aliesa.en.md) | 阿里云边缘安全加速 |
11 | | `callback` | 自定义API (Webhook) | [callback 中文文档](callback.md) | [callback English Doc](callback.en.md) | 自定义HTTP API |
12 | | `cloudflare` | [Cloudflare](https://www.cloudflare.com/) | [cloudflare 中文文档](cloudflare.md) | [cloudflare English Doc](cloudflare.en.md) | 全球CDN和DNS服务 |
13 | | `debug` | 调试Provider | [debug 中文文档](debug.md) | [debug English Doc](debug.en.md) | 仅打印IP地址,用于调试|
14 | | `dnscom`(51dns) | [51DNS](https://www.51dns.com/) | [51dns 中文文档](51dns.md) | [51dns English Doc](51dns.en.md) | ⚠️ 等待验证 |
15 | | `dnspod_com` | [DNSPod Global](https://www.dnspod.com/) | [dnspod_com 中文文档](dnspod_com.md) | [dnspod_com English Doc](dnspod_com.en.md) | ⚠️ 等待验证 |
16 | | `dnspod` | [DNSPod 中国版](https://www.dnspod.cn/) | [dnspod 中文文档](dnspod.md) | [dnspod English Doc](dnspod.en.md) | 国内最大DNS服务商|
17 | | `he` | [HE.net](https://dns.he.net/) | [he 中文文档](he.md) | [he English Doc](he.en.md) | ⚠️ 等待验证,不支持自动创建记录 |
18 | | `huaweidns` | [华为云 DNS](https://www.huaweicloud.com/product/dns.html) | [huaweidns 中文文档](huaweidns.md) | [huaweidns English Doc](huaweidns.en.md) | ⚠️ 等待验证 |
19 | | `namesilo` | [NameSilo](https://www.namesilo.com/) | [namesilo 中文文档](namesilo.md) | [namesilo English Doc](namesilo.en.md) | ⚠️ 等待验证 |
20 | | `noip` | [No-IP](https://www.noip.com/) | [noip 中文文档](noip.md) | [noip English Doc](noip.en.md) | 不支持自动创建记录 |
21 | | `tencentcloud` | [腾讯云DNSPod](https://cloud.tencent.com/product/dns) | [tencentcloud 中文文档](tencentcloud.md) | [tencentcloud English Doc](tencentcloud.en.md) | 腾讯云DNSPod服务 |
22 | | `edgeone` | [腾讯云EdgeOne](https://cloud.tencent.com/product/teo) | [edgeone 中文文档](edgeone.md) | [edgeone English Doc](edgeone.en.md) | 腾讯云边缘安全加速平台(加速域名) |
23 | | `edgeone_dns` | [腾讯云EdgeOne DNS](https://cloud.tencent.com/product/teo) | [edgeone_dns 中文文档](edgeone_dns.md) | [edgeone_dns English Doc](edgeone_dns.en.md) | 腾讯云EdgeOne DNS记录管理 |
24 |
25 | > 添加新的Provider, [创建Issue,并按照模板填好链接](https://github.com/NewFuture/DDNS/issues/new?template=new-dns-provider.md)
26 |
27 | ### 支持自动创建记录
28 |
29 | 大部分provider支持自动创建不存在的DNS记录,但有例外:
30 |
31 | - ⚠️ **he**: 不支持自动创建记录,需要手动在控制面板中预先创建
32 | - ⚠️ **noip**: 不支持自动创建记录,需要手动在控制面板中预先创建
33 |
34 | ## 📝 配置示例
35 |
36 | ### 命令行配置
37 |
38 | [cli 提供了命令行配置方式](../config/cli.md),以下是一些常用的命令行示例:
39 |
40 | ```bash
41 | # DNSPod中国版
42 | ddns --dns dnspod --id 12345 --token your_token --ipv4 ddns.newfuture.cc
43 |
44 | # 阿里云DNS
45 | ddns --dns alidns --id your_access_key --token your_secret --ipv4 ddns.newfuture.cc
46 |
47 | # Cloudflare (使用邮箱)
48 | ddns --dns cloudflare --id user@example.com --token your_api_key --ipv4 ddns.newfuture.cc
49 |
50 | # Cloudflare (使用Token)
51 | ddns --dns cloudflare --token your_api_token --ipv4 ddns.newfuture.cc
52 |
53 | # 腾讯云EdgeOne
54 | ddns --dns edgeone --id your_secret_id --token your_secret_key --ipv4 ddns.newfuture.cc
55 |
56 | # No-IP
57 | ddns --dns noip --id your_username --token your_password --ipv4 ddns.newfuture.cc
58 | ```
59 |
60 | ### JSON配置文件
61 |
62 | [JSON配置文件](../config/json.md)提供了更灵活的配置方式,以下是一些常用的JSON配置示例:
63 |
64 | #### 单Provider格式
65 |
66 | ```json
67 | {
68 | "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
69 | "dns": "dnspod",
70 | "id": "12345",
71 | "token": "your_token_here",
72 | "ipv4": ["ddns.newfuture.cc", "*.newfuture.cc"],
73 | "index4": ["default"],
74 | "ttl": 600
75 | }
76 | ```
77 |
78 | #### 多Provider格式
79 |
80 | ```json
81 | {
82 | "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
83 | "ssl": "auto",
84 | "cache": true,
85 | "log": {"level": "INFO"},
86 | "providers": [
87 | {
88 | "provider": "cloudflare",
89 | "id": "user@example.com",
90 | "token": "cloudflare-token",
91 | "ipv4": ["cf.example.com"],
92 | "ttl": 300
93 | },
94 | {
95 | "provider": "dnspod",
96 | "id": "12345",
97 | "token": "dnspod-token",
98 | "ipv4": ["dnspod.example.com"],
99 | "ttl": 600
100 | }
101 | ]
102 | }
103 | ```
104 |
105 | ### 多配置文件方式
106 |
107 | #### 命令行指定多个配置文件
108 |
109 | ```bash
110 | # 使用多个独立的配置文件
111 | ddns -c cloudflare.json -c dnspod.json -c alidns.json
112 |
113 | # 使用环境变量指定多个配置文件
114 | export DDNS_CONFIG="cloudflare.json,dnspod.json,alidns.json"
115 | ddns
116 | ```
117 |
118 | #### 多配置文件示例
119 |
120 | **cloudflare.json**:
121 |
122 | ```json
123 | {
124 | "dns": "cloudflare",
125 | "id": "user@example.com",
126 | "token": "your-cloudflare-token",
127 | "ipv4": ["cf.example.com"]
128 | }
129 | ```
130 |
131 | **dnspod.json**:
132 |
133 | ```json
134 | {
135 | "dns": "dnspod",
136 | "id": "12345",
137 | "token": "your-dnspod-token",
138 | "ipv4": ["dnspod.example.com"]
139 | }
140 | ```
141 |
142 | ### 环境变量配置
143 |
144 | [环境变量配置](../config/env.md)提供了另一种配置方式,以下是一些常用的环境变量示例:
145 |
146 | ```bash
147 | export DDNS_DNS=dnspod
148 | export DDNS_ID=12345
149 | export DDNS_TOKEN=your_token_here
150 | export DDNS_IPV4=ddns.newfuture.cc
151 | export DDNS_INDEX4=default
152 | ddns --debug
153 | ```
154 |
155 | ## 📚 相关文档
156 |
157 | - [命令行配置](../config/cli.md) - 命令行参数详细说明
158 | - [JSON配置](../config/json.md) - JSON配置文件格式说明
159 | - [环境变量配置](../config/env.md) - 环境变量配置方式
160 | - [Provider开发指南](../dev/provider.md) - 如何开发新的provider
161 | - [JSON Schema](../../schema/v4.0.json) - 配置文件验证架构
162 |
163 | ---
164 |
165 | 如有疑问或需要帮助,请查看[FAQ](../../README.md#FAQ) 或在 [GitHub Issues](https://github.com/NewFuture/DDNS/issues) 中提问。
166 |
--------------------------------------------------------------------------------