From bb4d8473db8d604e7b25f70f2ed8a539bcaa0d2b Mon Sep 17 00:00:00 2001 From: wood chen Date: Sun, 12 Jan 2025 03:47:20 +0800 Subject: [PATCH] first commit --- .env.example | 42 ++++ .github/workflows/docker-publish.yml | 40 ++++ .gitignore | 1 + Dockerfile | 17 ++ config.py | 93 +++++++++ docker-compose.yml | 8 + main.py | 282 +++++++++++++++++++++++++++ readme.md | 118 +++++++++++ requirements.txt | 4 + 9 files changed, 605 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 readme.md create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2988bb2 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# DNSPOD API 配置 +DNSPOD_ID=your_id_here +DNSPOD_TOKEN=your_token_here + +# 域名配置 - 域名1 +DOMAIN_1=example1.com +SUB_DOMAIN_1=@ # 子域名,@ 表示根域名 +REMARK_1=优选IP # 记录备注 +TTL_1=600 # TTL值(秒) +UPDATE_INTERVAL_1=15 # 更新间隔(分钟) +IPV4_ENABLED_1=true # 是否启用IPv4记录 +IPV6_ENABLED_1=true # 是否启用IPv6记录 +ENABLED_1=true # 是否启用此域名配置 + +# 域名配置 - 域名2(更频繁的更新) +DOMAIN_2=example2.com +SUB_DOMAIN_2=www +REMARK_2=CF优选 # 记录备注 +TTL_2=300 # 更短的TTL +UPDATE_INTERVAL_2=5 # 更频繁的更新 +IPV4_ENABLED_2=true # 只启用IPv4 +IPV6_ENABLED_2=false # 不启用IPv6 +ENABLED_2=true + +# 域名配置 - 域名3(更长的缓存) +DOMAIN_3=example3.com +SUB_DOMAIN_3=* +REMARK_3=CloudFlare优选 # 记录备注 +TTL_3=1800 # 更长的TTL +UPDATE_INTERVAL_3=30 # 更长的更新间隔 +IPV4_ENABLED_3=true # 启用IPv4 +IPV6_ENABLED_3=true # 启用IPv6 +ENABLED_3=true + +# 可以继续添加更多域名配置... +# 每个域名可以独立控制: +# - 是否启用IPv4 (IPV4_ENABLED_n) +# - 是否启用IPv6 (IPV6_ENABLED_n) +# - TTL值 (TTL_n) +# - 更新间隔 (UPDATE_INTERVAL_n) +# - 是否启用 (ENABLED_n) +# - 记录备注 (REMARK_n) \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b6221fa --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,40 @@ +name: Docker + +on: + push: + branches: + - main + tags: + - v* + +env: + IMAGE_NAME: dnspod-yxip + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: woodchen + password: ${{ secrets.ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: woodchen/${{ env.IMAGE_NAME }}:latest + platforms: linux/amd64,linux/arm64 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d7901e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8609e79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 复制项目文件 +COPY requirements.txt . +COPY config.py . +COPY main.py . + +# 安装依赖 +RUN pip install -r requirements.txt + +# 创建配置文件和日志目录 +RUN mkdir -p logs && touch .env + +# 运行程序 +CMD ["python", "main.py"] \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..80ff666 --- /dev/null +++ b/config.py @@ -0,0 +1,93 @@ +import os +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +# DNSPOD API配置 +DNSPOD_ID = os.getenv("DNSPOD_ID") +DNSPOD_TOKEN = os.getenv("DNSPOD_TOKEN") + +# API接口配置 +API_URL = "https://api.vvhan.com/tool/cf_ip" + +# 默认配置 +DEFAULT_TTL = 600 +DEFAULT_UPDATE_INTERVAL = 15 + +# 支持的线路类型 +LINE_TYPES = ["默认", "移动", "联通", "电信"] + +# 支持的记录类型 +RECORD_TYPES = ["A", "AAAA"] + +# 域名配置 +DOMAINS = [] + +# 从环境变量加载域名配置 +i = 1 +while True: + domain_key = f"DOMAIN_{i}" + if not os.getenv(domain_key): + break + + # 获取基本配置 + base_config = { + "domain": os.getenv(domain_key), + "sub_domain": os.getenv(f"SUB_DOMAIN_{i}", "@"), + "line": LINE_TYPES, + "ttl": int(os.getenv(f"TTL_{i}", str(DEFAULT_TTL))), + "update_interval": int( + os.getenv(f"UPDATE_INTERVAL_{i}", str(DEFAULT_UPDATE_INTERVAL)) + ), + "enabled": os.getenv(f"ENABLED_{i}", "true").lower() == "true", + "remark": os.getenv(f"REMARK_{i}", "YXIP"), + } + + # 检查是否启用IPv4 + ipv4_enabled = os.getenv(f"IPV4_ENABLED_{i}", "true").lower() == "true" + # 检查是否启用IPv6 + ipv6_enabled = os.getenv(f"IPV6_ENABLED_{i}", "true").lower() == "true" + + # 为每个启用的记录类型创建配置 + if ipv4_enabled: + ipv4_config = base_config.copy() + ipv4_config["record_type"] = "A" + DOMAINS.append(ipv4_config) + + if ipv6_enabled: + ipv6_config = base_config.copy() + ipv6_config["record_type"] = "AAAA" + DOMAINS.append(ipv6_config) + + i += 1 + +# 如果没有配置任何域名,使用默认配置 +if not DOMAINS: + base_config = { + "domain": os.getenv("DOMAIN", "example.com"), + "sub_domain": os.getenv("SUB_DOMAIN", "@"), + "line": LINE_TYPES, + "ttl": int(os.getenv("TTL", str(DEFAULT_TTL))), + "update_interval": int( + os.getenv("UPDATE_INTERVAL", str(DEFAULT_UPDATE_INTERVAL)) + ), + "enabled": True, + } + + # 检查默认的IPv4和IPv6设置 + ipv4_enabled = os.getenv("IPV4_ENABLED", "true").lower() == "true" + ipv6_enabled = os.getenv("IPV6_ENABLED", "true").lower() == "true" + + if ipv4_enabled: + ipv4_config = base_config.copy() + ipv4_config["record_type"] = "A" + DOMAINS.append(ipv4_config) + + if ipv6_enabled: + ipv6_config = base_config.copy() + ipv6_config["record_type"] = "AAAA" + DOMAINS.append(ipv6_config) + +# 日志配置 +LOG_LEVEL = "INFO" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9b4e892 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + dnspod-yxip: + image: woodchen/dnspod-yxip:latest + container_name: dnspod-yxip + restart: always + volumes: + - ./.env:/app/.env:ro # 映射环境变量文件 + - ./logs:/app/logs # 映射日志目录 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..de5f3ce --- /dev/null +++ b/main.py @@ -0,0 +1,282 @@ +import json +import time +import requests +import schedule +from loguru import logger +from typing import Dict, List, Optional, Tuple +import config + +# 配置日志 +logger.add("dnspod.log", rotation="10 MB", level=config.LOG_LEVEL) + + +class DNSPodManager: + def __init__(self): + self.api_url = "https://dnsapi.cn" + self.common_params = { + "login_token": f"{config.DNSPOD_ID},{config.DNSPOD_TOKEN}", + "format": "json", + "lang": "cn", + "error_on_empty": "no", + } + # 记录每个域名每种记录类型的最后更新时间 + self.last_update = {} # 格式: {domain: {'A': timestamp, 'AAAA': timestamp}} + + def _api_request(self, path: str, data: Dict) -> Dict: + """发送API请求""" + try: + url = f"{self.api_url}/{path}" + response = requests.post(url, data={**self.common_params, **data}) + result = response.json() + + if int(result.get("status", {}).get("code", -1)) != 1: + raise Exception(result.get("status", {}).get("message", "未知错误")) + + return result + except Exception as e: + logger.error(f"API请求失败: {str(e)}") + raise + + def get_optimal_ips(self) -> Dict: + """获取优选IP""" + try: + response = requests.get(config.API_URL) + data = response.json() + if data.get("success"): + return data["data"] + raise Exception("API返回数据格式错误") + except Exception as e: + logger.error(f"获取优选IP失败: {str(e)}") + return None + + def find_best_ip(self, ip_data: Dict, ip_version: str) -> Optional[Tuple[str, int]]: + """查找延迟最低的IP,返回(IP, 延迟)""" + best_ip = None + min_latency = float("inf") + + # 遍历所有线路 + for line_key in ["CM", "CU", "CT"]: + if line_key in ip_data[ip_version]: + ips = ip_data[ip_version][line_key] + for ip_info in ips: + if ip_info["latency"] < min_latency: + min_latency = ip_info["latency"] + best_ip = (ip_info["ip"], ip_info["latency"]) + + return best_ip + + def find_line_best_ip( + self, ip_data: Dict, ip_version: str, line_key: str + ) -> Optional[Tuple[str, int]]: + """查找指定线路延迟最低的IP""" + if line_key not in ip_data[ip_version]: + return None + + ips = ip_data[ip_version][line_key] + if not ips: + return None + + best_ip = min(ips, key=lambda x: x["latency"]) + return (best_ip["ip"], best_ip["latency"]) + + def get_record_list(self, domain: str) -> List: + """获取域名记录列表""" + try: + result = self._api_request("Record.List", {"domain": domain}) + return result.get("records", []) + except Exception as e: + logger.error(f"获取记录列表失败: {str(e)}") + return [] + + def delete_record(self, domain: str, record_id: str) -> bool: + """删除DNS记录""" + try: + self._api_request( + "Record.Remove", {"domain": domain, "record_id": record_id} + ) + return True + except Exception as e: + logger.error(f"删除记录失败: {str(e)}") + return False + + def handle_record_conflicts(self, domain: str, sub_domain: str, record_type: str): + """处理记录冲突""" + records = self.get_record_list(domain) + for record in records: + # 如果是要添加A记录,需要删除CNAME记录 + if ( + record_type == "A" + and record["type"] == "CNAME" + and record["name"] == sub_domain + ): + logger.info(f"删除冲突的CNAME记录: {domain} - {sub_domain}") + self.delete_record(domain, record["id"]) + # 如果是要添加CNAME记录,需要删除A记录 + elif ( + record_type == "CNAME" + and record["type"] == "A" + and record["name"] == sub_domain + ): + logger.info(f"删除冲突的A记录: {domain} - {sub_domain}") + self.delete_record(domain, record["id"]) + + def update_record( + self, + domain: str, + sub_domain: str, + record_type: str, + line: str, + value: str, + ttl: int, + remark: str = "YXIP", + ) -> bool: + """更新DNS记录""" + try: + # 处理记录冲突 + self.handle_record_conflicts(domain, sub_domain, record_type) + + # 获取域名记录列表 + records = self.get_record_list(domain) + + # 查找匹配的记录 + record_id = None + for record in records: + if ( + record["name"] == sub_domain + and record["line"] == line + and record["type"] == record_type + ): + record_id = record["id"] + break + + # 更新或创建记录 + data = { + "domain": domain, + "sub_domain": sub_domain, + "record_type": record_type, + "record_line": line, + "value": value, + "ttl": ttl, + "remark": remark, + } + + if record_id: + data["record_id"] = record_id + self._api_request("Record.Modify", data) + else: + self._api_request("Record.Create", data) + return True + except Exception as e: + logger.error(f"更新DNS记录失败: {str(e)}") + return False + + def update_domain_records(self, domain_config: Dict) -> None: + """更新单个域名的记录""" + domain = domain_config["domain"] + sub_domain = domain_config["sub_domain"] + record_type = domain_config["record_type"] + ttl = domain_config["ttl"] + remark = domain_config["remark"] + + # 获取优选IP数据 + ip_data = self.get_optimal_ips() + if not ip_data: + return + + # 获取对应版本的IP数据 + ip_version = "v6" if record_type == "AAAA" else "v4" + if ip_version not in ip_data: + logger.warning( + f"未找到{ip_version}版本的IP数据,跳过更新 {domain} 的 {record_type} 记录" + ) + return + + # 检查是否有可用的IP数据 + has_ip_data = False + for line_key in ["CM", "CU", "CT"]: + if line_key in ip_data[ip_version] and ip_data[ip_version][line_key]: + has_ip_data = True + break + + if not has_ip_data: + logger.warning( + f"没有可用的{ip_version}版本IP数据,跳过更新 {domain} 的 {record_type} 记录" + ) + return + + # 先处理默认线路 + best_ip = self.find_best_ip(ip_data, ip_version) + if best_ip: + ip, latency = best_ip + logger.info( + f"更新{record_type}记录: {domain} - {sub_domain} - 默认 - {ip} (延迟: {latency}ms)" + ) + self.update_record(domain, sub_domain, record_type, "默认", ip, ttl, remark) + + # 更新其他线路的记录 + for line in domain_config["line"]: + if line == "默认": + continue + + if line == "移动": + line_key = "CM" + elif line == "联通": + line_key = "CU" + elif line == "电信": + line_key = "CT" + else: + continue + + if line_key in ip_data[ip_version]: + best_ip = self.find_line_best_ip(ip_data, ip_version, line_key) + if best_ip: + ip, latency = best_ip + logger.info( + f"更新{record_type}记录: {domain} - {sub_domain} - {line} - {ip} (延迟: {latency}ms)" + ) + self.update_record( + domain, sub_domain, record_type, line, ip, ttl, remark + ) + + def check_and_update(self): + """检查并更新所有域名""" + current_time = time.time() + + for domain_config in config.DOMAINS: + if not domain_config["enabled"]: + continue + + domain = domain_config["domain"] + record_type = domain_config["record_type"] + update_interval = domain_config["update_interval"] * 60 # 转换为秒 + + # 初始化域名的更新时间记录 + if domain not in self.last_update: + self.last_update[domain] = {} + + # 获取该记录类型的最后更新时间 + last_update = self.last_update[domain].get(record_type, 0) + + # 检查是否需要更新 + if current_time - last_update >= update_interval: + logger.info(f"开始更新域名: {domain} 的 {record_type} 记录") + self.update_domain_records(domain_config) + self.last_update[domain][record_type] = current_time + + +def main(): + manager = DNSPodManager() + + # 首次运行,更新所有域名 + manager.check_and_update() + + # 每分钟检查一次是否需要更新 + schedule.every(1).minutes.do(manager.check_and_update) + + while True: + schedule.run_pending() + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4524dad --- /dev/null +++ b/readme.md @@ -0,0 +1,118 @@ +# DNSPOD 优选域名 + +根据API接口返回的IP地址, 在DNSPOD中创建或者更新域名的解析记录, 支持分线路和多权重。 + +支持docker运行, 需要配置DNSPOD的ID和TOKEN, 可以配置多个域名。默认每15分钟自动获取IP地址并更新DNSPOD。 + +## 特性 + +- 自动获取优选IP地址 +- 支持IPv4和IPv6 +- 支持移动、联通、电信三个线路 +- 自动选择延迟最低的IP +- 支持多域名配置 +- 可配置的更新间隔和TTL +- 完整的日志记录 +- Docker支持 + +## 快速开始 + +### 使用 Docker Compose + +1. 创建配置文件: +```bash +cp .env.example .env +``` + +2. 编辑 `.env` 文件,填写您的配置: +```ini +# DNSPOD API 配置 +DNSPOD_ID=your_id_here +DNSPOD_TOKEN=your_token_here + +# 域名配置 - 域名1 +DOMAIN_1=example1.com +SUB_DOMAIN_1=@ # 子域名,@ 表示根域名 +REMARK_1=优选IP # 记录备注 +TTL_1=600 # TTL值(秒) +UPDATE_INTERVAL_1=15 # 更新间隔(分钟) +IPV4_ENABLED_1=true # 是否启用IPv4记录 +IPV6_ENABLED_1=true # 是否启用IPv6记录 +ENABLED_1=true # 是否启用此域名配置 +``` + +3. 拉取并运行容器: +```bash +docker compose pull +docker compose up -d +``` + +### 手动构建运行 + +1. 克隆仓库: +```bash +git clone https://github.com/your-username/dnspod-yxip.git +cd dnspod-yxip +``` + +2. 创建并编辑配置文件: +```bash +cp .env.example .env +# 编辑 .env 文件 +``` + +3. 构建镜像: +```bash +docker compose build +``` + +4. 运行容器: +```bash +docker compose up -d +``` + +## 配置说明 + +每个域名配置包含以下参数: +- `DOMAIN_n`: 域名 +- `SUB_DOMAIN_n`: 子域名,@ 表示根域名,* 表示泛解析 +- `REMARK_n`: 记录备注 +- `TTL_n`: TTL值(秒) +- `UPDATE_INTERVAL_n`: 更新间隔(分钟) +- `IPV4_ENABLED_n`: 是否启用IPv4记录 +- `IPV6_ENABLED_n`: 是否启用IPv6记录 +- `ENABLED_n`: 是否启用此域名配置 + +其中 n 是域名编号(1, 2, 3...),可以配置任意数量的域名。 + +## 日志查看 + +日志文件保存在 `logs` 目录下: +```bash +# 查看实时日志 +docker compose logs -f + +# 查看日志文件 +cat logs/dnspod.log +``` + +## 更新 + +1. 拉取最新镜像: +```bash +docker compose pull +``` + +2. 重新启动容器: +```bash +docker compose up -d +``` + +## 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 许可证 + +MIT License + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8fdac3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests>=2.31.0 +python-dotenv>=1.0.0 +schedule>=1.2.1 +loguru>=0.7.2 \ No newline at end of file