Browse Source

feat(config): 支持环境变量传递数组参数 Support array in environment variables and improve logic (#476)

* feat(config): 环境变量支持数组
* 简单参数支持‘,’分割
* fix typo and update doc
* 优化逻辑
New Future 5 months ago
parent
commit
46918ef98d
5 changed files with 427 additions and 15 deletions
  1. 7 1
      README.md
  2. 361 0
      doc/env.md
  3. 12 1
      run.py
  4. 6 6
      schema/v4.0.json
  5. 41 7
      util/config.py

+ 7 - 1
README.md

@@ -16,8 +16,12 @@
 
 - 兼容和跨平台:
   - [Docker (@NN708)](https://hub.docker.com/r/newfuture/ddns) [![Docker Image Size](https://img.shields.io/docker/image-size/newfuture/ddns/latest?logo=docker&style=social)](https://hub.docker.com/r/newfuture/ddns)[![Docker Platforms](https://img.shields.io/badge/arch-amd64%20%7C%20arm64%20%7C%20arm%2Fv7%20%7C%20arm%2Fv6%20%7C%20ppc64le%20%7C%20s390x%20%7C%20386%20%7C%20mips64le-blue?style=social)](https://hub.docker.com/r/newfuture/ddns)
-  - [PIP 安装 (兼容Python2)](https://pypi.org/project/ddns/) ![PyPI - Wheel](https://img.shields.io/pypi/wheel/ddns.svg?logo=pypi&style=social) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ddns.svg?style=social)
   - [二进制文件](https://github.com/NewFuture/DDNS/releases/latest) ![cross platform](https://img.shields.io/badge/system-windows_%7C%20linux_%7C%20mac-success.svg?style=social)
+  
+- 配置方式:
+  - [命令行参数](#详细配置)
+  - [JSON 配置文件](#详细配置)
+  - [环境变量配置](doc/env.md) 📖
 
 - 域名支持:
   - 多个域名支持
@@ -116,6 +120,8 @@
 2. JSON 配置文件(值为 null 认为是有效值,会覆盖环境变量的设置,如果没有对应的 key 则会尝试使用环境变量)
 3. 环境变量 DDNS_ 前缀加上 key 全大写或者全小写,点转下划线(`${ddns_id}` 或 `${DDNS_ID}`,`${DDNS_LOG_LEVEL}`)
 
+> 📖 **环境变量详细配置**: 查看 [环境变量配置文档](doc/env.md) 了解所有环境变量的详细用法和示例
+
 <details open>
 <summary markdown="span">config.json 配置文件</summary>
 

+ 361 - 0
doc/env.md

@@ -0,0 +1,361 @@
+# DDNS 环境变量配置文档
+
+## 概述
+
+DDNS 支持通过环境变量进行配置,环境变量的优先级为:**命令行参数 > 配置文件 > 环境变量**
+
+所有环境变量都以 `DDNS_` 为前缀,后跟参数名(推荐全大写),点号(`.`)替换为下划线(`_`)。
+
+## 环境变量命名规则
+
+| 配置参数 | 环境变量名称 | 示例 |
+|---------|-------------|------|
+| `id` | `DDNS_ID` 或 `ddns_id` | `DDNS_ID=12345` |
+| `token` | `DDNS_TOKEN` 或 `ddns_token` | `DDNS_TOKEN=mytokenkey` |
+| `log.level` | `DDNS_LOG_LEVEL` 或 `ddns_log_level` | `DDNS_LOG_LEVEL=DEBUG` |
+| `log.file` | `DDNS_LOG_FILE` 或 `ddns_log_file` | `DDNS_LOG_FILE=/var/log/ddns.log` |
+
+## 基础配置参数
+
+### 认证信息
+
+#### DDNS_ID
+
+- **类型**: 字符串
+- **必需**: 是(部分 DNS 服务商可选)
+- **说明**: API 访问 ID 或用户标识
+- **示例**:
+
+  ```bash
+  export DDNS_ID="12345"
+  # DNSPod 为用户 ID
+  # 阿里云为 Access Key ID
+  # CloudFlare 为邮箱地址(使用 Token 时可留空)
+  # HE.net 可留空
+  # 华为云为 Access Key ID (AK)
+  ```
+
+#### DDNS_TOKEN
+
+- **类型**: 字符串
+- **必需**: 是
+- **说明**: API 授权令牌或密钥
+- **示例**:
+
+  ```bash
+  export DDNS_TOKEN="your_api_token_here"
+  # 部分平台称为 Secret Key
+  # 注意:请妥善保管,不要泄露
+  ```
+
+### DNS 服务商
+
+#### DDNS_DNS
+
+- **类型**: 字符串
+- **必需**: 否
+- **默认值**: `dnspod`
+- **可选值**: `alidns`, `cloudflare`, `dnscom`, `dnspod`, `dnspod_com`, `he`, `huaweidns`, `callback`
+- **说明**: DNS 服务提供商
+- **示例**:
+
+  ```bash
+  export DDNS_DNS="alidns"        # 阿里云 DNS
+  export DDNS_DNS="cloudflare"    # CloudFlare
+  export DDNS_DNS="dnspod"        # DNSPod 国内版
+  export DDNS_DNS="dnspod_com"    # DNSPod 国际版
+  export DDNS_DNS="he"            # HE.net
+  export DDNS_DNS="huaweidns"     # 华为云 DNS
+  export DDNS_DNS="callback"      # 自定义回调
+  ```
+
+## 域名配置
+
+### IPv4 域名列表
+
+#### DDNS_IPV4
+
+- **类型**: 数组(支持 JSON/python 格式)
+- **必需**: 否
+- **默认值**: `[]`
+- **说明**: 需要更新 IPv4 记录的域名列表
+- **示例**:
+
+  ```bash
+  # JSON 数组格式(推荐)
+  export DDNS_IPV4='["example.com", "sub.example.com"]'
+  
+  # 单个域名
+  export DDNS_IPV4="example.com"
+  
+  # 禁用 IPv4 更新
+  export DDNS_IPV4="[]"
+  ```
+
+### IPv6 域名列表
+
+#### DDNS_IPV6
+
+- **类型**: 数组(支持 JSON / Python 格式)
+- **必需**: 否
+- **默认值**: `[]`
+- **说明**: 需要更新 IPv6 记录的域名列表
+- **示例**:
+
+  ```bash
+  # 单个域名
+  export DDNS_IPV6="ipv6.example.com"
+
+  # JSON 数组格式(推荐)
+  export DDNS_IPV6='["ipv6.example.com", "v6.example.com"]'
+
+  # python 列表格式
+  export DDNS_IPV6="['ipv6.example.com', 'v6.example.com']"
+
+  # 禁用 IPv6 更新
+  export DDNS_IPV6="[]"
+  ```
+
+## IP 获取方式
+
+### IPv4 获取方式
+
+#### DDNS_INDEX4
+
+- **类型**: 字符串或数组
+- **必需**: 否
+- **默认值**: `default`
+- **说明**: IPv4 地址获取方式
+- **示例**:
+
+  ```bash
+  # 默认方式(系统默认外网 IP)
+  export DDNS_INDEX4="default"
+  
+  # 公网 IP
+  export DDNS_INDEX4="public"
+  
+  # 指定网卡(第 0 个网卡)
+  export DDNS_INDEX4="0"
+  
+  # 自定义 URL 获取
+  export DDNS_INDEX4="url:http://ip.sb"
+  
+  # 正则匹配(注意转义)
+  export DDNS_INDEX4="regex:192\\.168\\..*"
+  
+  # 执行命令
+  export DDNS_INDEX4="cmd:curl -s http://ipv4.icanhazip.com"
+  
+  # Shell 命令
+  export DDNS_INDEX4="shell:ip route get 8.8.8.8 | awk '{print \$7}'"
+  
+  # 多种方式组合(JSON 数组)
+  export DDNS_INDEX4='["public", "regex:172\\..*"]'
+  
+  # 禁用 IPv4 获取
+  export DDNS_INDEX4="false"
+  ```
+
+### IPv6 获取方式
+
+#### DDNS_INDEX6
+
+- **类型**: 字符串或数组
+- **必需**: 否
+- **默认值**: `default`
+- **说明**: IPv6 地址获取方式(用法同 INDEX4)
+- **示例**:
+
+  ```bash
+  # 公网 IPv6
+  export DDNS_INDEX6="public"
+  
+  # 正则匹配 IPv6
+  export DDNS_INDEX6="regex:2001:.*"
+  
+  # 自定义 URL
+  export DDNS_INDEX6="url:http://ipv6.icanhazip.com"
+  
+  # 禁用 IPv6 获取
+  export DDNS_INDEX6="false"
+  ```
+
+## 网络配置
+
+### TTL 设置
+
+#### DDNS_TTL
+
+- **类型**: 整数
+- **必需**: 否
+- **默认值**: `null`(使用 DNS 默认值)
+- **说明**: DNS 记录的 TTL(生存时间),单位为秒
+- **示例**:
+
+  ```bash
+  export DDNS_TTL="600"     # 10 分钟
+  export DDNS_TTL="3600"    # 1 小时
+  export DDNS_TTL="86400"   # 24 小时
+  ```
+
+### 代理设置
+
+#### DDNS_PROXY
+
+- **类型**: 数组(支持 JSON 格式或分号分隔)
+- **必需**: 否
+- **默认值**: 无
+- **说明**: HTTP 代理设置,支持多代理轮换
+- **示例**:
+
+  ```bash
+  # 单个代理
+  export DDNS_PROXY="http://127.0.0.1:1080"
+  
+  # 多个代理(JSON 数组格式)
+  export DDNS_PROXY='["http://proxy1:8080", "http://proxy2:8080", "DIRECT"]'
+  
+  # 分号分隔格式
+  export DDNS_PROXY="http://proxy1:8080;http://proxy2:8080;DIRECT"
+  
+  # DIRECT 表示直连
+  export DDNS_PROXY="DIRECT"
+  ```
+
+## 系统配置
+
+### 缓存设置
+
+#### DDNS_CACHE
+
+- **类型**: 布尔值或字符串
+- **必需**: 否
+- **默认值**: `true`
+- **说明**: 启用缓存以减少 API 请求
+- **示例**:
+
+  ```bash
+  # 启用缓存(默认路径)
+  export DDNS_CACHE="true"
+  
+  # 禁用缓存
+  export DDNS_CACHE="false"
+  
+  # 自定义缓存文件路径
+  export DDNS_CACHE="/path/to/ddns.cache"
+  ```
+
+### 日志配置
+
+#### DDNS_LOG_LEVEL
+
+- **类型**: 字符串
+- **必需**: 否
+- **默认值**: `INFO`
+- **可选值**: `CRITICAL`, `FATAL`, `ERROR`, `WARN`, `WARNING`, `INFO`, `DEBUG`, `NOTSET`
+- **说明**: 日志级别
+- **示例**:
+
+  ```bash
+  export DDNS_LOG_LEVEL="DEBUG"    # 调试模式
+  export DDNS_LOG_LEVEL="INFO"     # 信息模式
+  export DDNS_LOG_LEVEL="ERROR"    # 仅错误
+  ```
+
+#### DDNS_LOG_FILE
+
+- **类型**: 字符串
+- **必需**: 否
+- **默认值**: 无(输出到控制台)
+- **说明**: 日志文件路径
+- **示例**:
+
+  ```bash
+  export DDNS_LOG_FILE="/var/log/ddns.log"
+  export DDNS_LOG_FILE="./ddns.log"
+  ```
+
+## 使用示例
+
+### 基础配置示例
+
+```bash
+#!/bin/bash
+# DNSPod 配置示例
+export DDNS_DNS="dnspod"
+export DDNS_ID="12345"
+export DDNS_TOKEN="your_token_here"
+export DDNS_IPV4='["example.com", "www.example.com"]'
+export DDNS_IPV6='["ipv6.example.com"]'
+export DDNS_TTL="600"
+
+# 运行 DDNS
+ddns
+```
+
+### Docker 环境变量示例
+
+```bash
+docker run -d \
+  -e DDNS_DNS=dnspod \
+  -e DDNS_ID=12345 \
+  -e DDNS_TOKEN=your_token_here \
+  -e DDNS_IPV4=example.com \
+  -e DDNS_IPV6='["ipv6.example.com","example.com"]' \
+  -e DDNS_INDEX4=["public", 0] \
+  -e DDNS_INDEX6=public \
+  -e DDNS_TTL=600 \
+  -e DDNS_LOG_LEVEL=INFO \
+  --network host \
+  newfuture/ddns
+```
+
+### 复杂配置示例
+
+```bash
+#!/bin/bash
+# 阿里云 DNS 高级配置
+export DDNS_DNS="alidns"
+export DDNS_ID="your_access_key_id"
+export DDNS_TOKEN="your_access_key_secret"
+
+# 多域名配置
+export DDNS_IPV4='["ddns.example.com", "home.example.com", "server.example.com"]'
+export DDNS_IPV6='["ipv6.example.com"]'
+
+# 多种 IP 获取方式
+export DDNS_INDEX4='["public", "regex:192\\.168\\.1\\..*", "cmd:curl -s ipv4.icanhazip.com"]'
+export DDNS_INDEX6="public"
+
+# 代理和缓存配置
+export DDNS_PROXY='["http://proxy.example.com:8080", "DIRECT"]'
+export DDNS_CACHE="/home/user/.ddns_cache"
+
+# 日志配置
+export DDNS_LOG_LEVEL="DEBUG"
+export DDNS_LOG_FILE="/var/log/ddns.log"
+
+# TTL 设置
+export DDNS_TTL="300"
+
+# 运行
+ddns
+```
+
+## 注意事项
+
+1. **数组参数格式**: `index4`, `index6`, `ipv4`, `ipv6`, `proxy` 等数组参数支持两种格式:
+   - JSON 数组格式:`'["item1", "item2"]'`(推荐)
+   - 逗号分隔格式:`"item1,item2"`
+
+2. **环境变量优先级**: 环境变量会被配置文件中的非 null 值覆盖
+
+3. **大小写兼容**: 环境变量名支持大写、小写或混合大小写
+
+4. **安全提醒**:
+   - 请妥善保管 `DDNS_TOKEN` 等敏感信息
+   - 在脚本中使用时避免明文存储
+   - 考虑使用 `.env` 文件或密钥管理系统
+
+5. **调试建议**: 出现问题时,可设置 `DDNS_LOG_LEVEL=DEBUG` 获取详细日志信息

+ 12 - 1
run.py

@@ -32,6 +32,17 @@ Copyright (c) New Future (MIT License)
 environ["DDNS_VERSION"] = "${BUILD_VERSION}"
 
 
+def is_false(value):
+    """
+    判断值是否为 False
+    字符串 'false', 或者 False, 或者 'none';
+    0 不是 False
+    """
+    if isinstance(value, str):
+        return value.strip().lower() in ['false', 'none']
+    return value is False
+
+
 def get_ip(ip_type, index="default"):
     """
     get IP address
@@ -41,7 +52,7 @@ def get_ip(ip_type, index="default"):
     value = None
     try:
         debug("get_ip(%s, %s)", ip_type, index)
-        if index is False:  # disabled
+        if is_false(index):  # disabled
             return False
         elif isinstance(index, list):  # 如果获取到的规则是列表,则依次判断列表中每一个规则,直到获取到IP
             for i in index:

+ 6 - 6
schema/v4.0.json

@@ -6,13 +6,13 @@
   "properties": {
     "$schema": {
       "type": "string",
-      "title": "please use https://ddns.newfuture.cc/schema/v2.8.json",
-      "description": "请更换为 https://ddns.newfuture.cc/schema/v2.8.json",
-      "default": "https://ddns.newfuture.cc/schema/v2.8.json",
+      "title": "please use https://ddns.newfuture.cc/schema/v4.0.json",
+      "description": "请更换为 https://ddns.newfuture.cc/schema/v4.0.json",
+      "default": "https://ddns.newfuture.cc/schema/v4.0.json",
       "enum": [
-        "https://ddns.newfuture.cc/schema/v2.8.json",
-        "http://ddns.newfuture.cc/schema/v2.8.json",
-        "./schema/v2.8.json"
+        "https://ddns.newfuture.cc/schema/v4.0.json",
+        "http://ddns.newfuture.cc/schema/v4.0.json",
+        "./schema/v4.0.json"
       ]
     },
     "id": {

+ 41 - 7
util/config.py

@@ -4,6 +4,7 @@ from argparse import Action, ArgumentParser, Namespace, RawTextHelpFormatter
 from json import load as loadjson, dump as dumpjson
 from os import stat, environ, path
 from logging import error, getLevelName
+from ast import literal_eval
 
 import sys
 
@@ -13,6 +14,11 @@ __config = {}  # type: dict
 log_levels = ['CRITICAL', 'FATAL', 'ERROR',
               'WARN', 'WARNING', 'INFO', 'DEBUG', 'NOTSET']
 
+# 支持数组的参数列表
+ARRAY_PARAMS = ['index4', 'index6', 'ipv4', 'ipv6', 'proxy']
+# 简单数组,支持’,’, ‘;’ 分隔的参数列表
+SIMPLE_ARRAY_PARAMS = ['ipv4', 'ipv6', 'proxy']
+
 
 def str2bool(v):
     """
@@ -35,6 +41,32 @@ def log_level(value):
     return getLevelName(value.upper())
 
 
+def parse_array_string(value, enable_simple_split):
+    """
+    解析数组字符串
+    仅当 trim 之后以 '[' 开头以 ']' 结尾时,才尝试使用 ast.literal_eval 解析
+    默认返回原始字符串
+    """
+    if not isinstance(value, str):
+        return value
+
+    trimmed = value.strip()
+    if trimmed.startswith('[') and trimmed.endswith(']'):
+        try:
+            # 尝试使用 ast.literal_eval 解析数组
+            parsed_value = literal_eval(trimmed)
+            # 确保解析结果是列表或元组
+            if isinstance(parsed_value, (list, tuple)):
+                return list(parsed_value)
+        except (ValueError, SyntaxError) as e:
+            # 解析失败时返回原始字符串
+            error('Failed to parse array string: %s. Exception: %s', value, e)
+    elif enable_simple_split and ',' in trimmed:
+        # 尝试使用逗号或分号分隔符解析
+        return [item.strip() for item in trimmed.split(',') if item.strip()]
+    return value
+
+
 def init_config(description, doc, version):
     """
     配置
@@ -128,14 +160,16 @@ def get_config(key, default=None):
         return getattr(__cli_args, key)
     if key in __config:
         return __config.get(key)
+    # 检查环境变量
     env_name = 'DDNS_' + key.replace('.', '_')  # type:str
-    if env_name in environ:  # 环境变量
-        return environ.get(env_name)
-    elif env_name.upper() in environ:  # 大写环境变量
-        return environ.get(env_name.upper())
-    elif env_name.lower() in environ:  # 小写环境变量
-        return environ.get(env_name.lower())
-    return default
+    variations = [env_name, env_name.upper(), env_name.lower()]
+    value = next((environ.get(v) for v in variations if v in environ), None)
+
+    # 如果找到环境变量值且参数支持数组,尝试解析为数组
+    if value is not None and key in ARRAY_PARAMS:
+        return parse_array_string(value, key in SIMPLE_ARRAY_PARAMS)
+
+    return value if value is not None else default
 
 
 class ExtendAction(Action):