├── 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 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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}[^>]*>(.*?)".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 | --------------------------------------------------------------------------------