Compare commits

..

No commits in common. "a5ccb1f95196c8a8c8cf1c04fa0eb55f8ef8a891" and "5d60b822e23266a07a0ba231e26e1adc15788cef" have entirely different histories.

2 changed files with 56 additions and 148 deletions

View File

@ -7,15 +7,15 @@ APISIX 路由监听服务
import os import os
import sys import sys
import json
import time import time
import logging import logging
import requests import requests
import ipaddress import ipaddress
from typing import Set, Optional, Dict, List from typing import Set, Optional, Dict
from datetime import datetime import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ssl_manager import APISIXSSLManager, RateLimitError from ssl_manager import APISIXSSLManager
# 配置日志 # 配置日志
logging.basicConfig( logging.basicConfig(
@ -46,15 +46,20 @@ class RouteWatcher:
'X-API-KEY': self.apisix_admin_key, 'X-API-KEY': self.apisix_admin_key,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}) })
# 速率限制记录:{domain: retry_after_timestamp} def _get_apisix_headers(self):
self.rate_limited_domains: Dict[str, float] = {} """获取 APISIX Admin API 请求头"""
return {
'X-API-KEY': self.apisix_admin_key,
'Content-Type': 'application/json'
}
def get_all_routes(self) -> list: def get_all_routes(self) -> list:
"""获取所有路由""" """获取所有路由"""
try: try:
response = self.session.get( response = requests.get(
f"{self.apisix_admin_url}/apisix/admin/routes", f"{self.apisix_admin_url}/apisix/admin/routes",
headers=self._get_apisix_headers(),
timeout=10 timeout=10
) )
@ -72,8 +77,9 @@ class RouteWatcher:
def get_all_ssls(self) -> list: def get_all_ssls(self) -> list:
"""获取所有 SSL 配置""" """获取所有 SSL 配置"""
try: try:
response = self.session.get( response = requests.get(
f"{self.apisix_admin_url}/apisix/admin/ssls", f"{self.apisix_admin_url}/apisix/admin/ssls",
headers=self._get_apisix_headers(),
timeout=10 timeout=10
) )
@ -170,68 +176,8 @@ class RouteWatcher:
logger.info(f"域名已有 SSL 配置: {domain}") logger.info(f"域名已有 SSL 配置: {domain}")
return False return False
# 检查是否在速率限制期间
if domain in self.rate_limited_domains:
retry_after = self.rate_limited_domains[domain]
current_time = time.time()
if current_time < retry_after:
remaining_minutes = int((retry_after - current_time) / 60) + 1
logger.debug(f"域名 {domain} 仍在速率限制期间,剩余约 {remaining_minutes} 分钟后重试")
return False
else:
# 限制已解除,移除记录
del self.rate_limited_domains[domain]
logger.info(f"域名 {domain} 速率限制已解除,将重新尝试申请证书")
return True return True
def _handle_cert_request(self, primary_domain: str, additional_domains: List[str] = None):
"""处理证书申请(单个或多个域名)
Args:
primary_domain: 主域名
additional_domains: 额外域名列表可选
Returns:
bool: 是否成功
"""
domains_list = [primary_domain] + (additional_domains or [])
if additional_domains:
total_domains = len(additional_domains) + 1
logger.info(f"发现同一路由中的多个域名,合并申请证书: {primary_domain} + {additional_domains}")
else:
logger.info(f"发现新域名,准备申请证书: {primary_domain}")
try:
if self.ssl_manager.request_certificate(primary_domain, additional_domains):
if additional_domains:
logger.info(f"证书申请成功: {primary_domain} (包含 {len(additional_domains) + 1} 个域名)")
else:
logger.info(f"证书申请成功: {primary_domain}")
# 申请成功,清除速率限制记录
for d in domains_list:
self.rate_limited_domains.pop(d, None)
return True
else:
if additional_domains:
logger.error(f"证书申请失败: {primary_domain} + {additional_domains}")
else:
logger.error(f"证书申请失败: {primary_domain}")
return False
except RateLimitError as e:
logger.warning(f"域名 {e.domain} 遇到速率限制,将在 {datetime.fromtimestamp(e.retry_after_timestamp).strftime('%Y-%m-%d %H:%M:%S')} 后自动重试")
# 记录速率限制的域名和重试时间(所有相关域名)
for d in domains_list:
self.rate_limited_domains[d] = e.retry_after_timestamp
return False
except Exception as e:
if additional_domains:
logger.error(f"处理域名异常 {primary_domain} + {additional_domains}: {e}")
else:
logger.error(f"处理域名异常 {primary_domain}: {e}")
return False
def process_new_domains(self): def process_new_domains(self):
"""处理新域名""" """处理新域名"""
# 优化:只获取一次路由和 SSL 配置,避免重复 API 调用 # 优化:只获取一次路由和 SSL 配置,避免重复 API 调用
@ -259,10 +205,30 @@ class RouteWatcher:
if not domains_to_request: if not domains_to_request:
continue continue
# 处理证书申请(单个或多个域名) # 如果只有一个域名,单独申请
primary_domain = domains_to_request[0] if len(domains_to_request) == 1:
additional_domains = domains_to_request[1:] if len(domains_to_request) > 1 else None domain = domains_to_request[0]
self._handle_cert_request(primary_domain, additional_domains) logger.info(f"发现新域名,准备申请证书: {domain}")
try:
if self.ssl_manager.request_certificate(domain):
logger.info(f"证书申请成功: {domain}")
else:
logger.error(f"证书申请失败: {domain}")
except Exception as e:
logger.error(f"处理域名异常 {domain}: {e}")
else:
# 多个域名,合并到一个证书申请(使用 SAN
primary_domain = domains_to_request[0]
additional_domains = domains_to_request[1:]
total_domains = len(additional_domains) + 1
logger.info(f"发现同一路由中的多个域名,合并申请证书: {primary_domain} + {additional_domains}")
try:
if self.ssl_manager.request_certificate(primary_domain, additional_domains):
logger.info(f"证书申请成功: {primary_domain} (包含 {total_domains} 个域名)")
else:
logger.error(f"证书申请失败: {primary_domain} + {additional_domains}")
except Exception as e:
logger.error(f"处理域名异常 {primary_domain} + {additional_domains}: {e}")
def run(self, interval: int = 60): def run(self, interval: int = 60):
"""运行监听服务""" """运行监听服务"""

View File

@ -32,14 +32,6 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RateLimitError(Exception):
"""速率限制异常"""
def __init__(self, domain: str, retry_after_timestamp: float):
self.domain = domain
self.retry_after_timestamp = retry_after_timestamp
super().__init__(f"域名 {domain} 遇到速率限制,请在 {datetime.fromtimestamp(retry_after_timestamp).strftime('%Y-%m-%d %H:%M:%S')} 后重试")
# 默认配置(可通过环境变量覆盖) # 默认配置(可通过环境变量覆盖)
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
'apisix_admin_url': 'http://localhost:9180', 'apisix_admin_url': 'http://localhost:9180',
@ -353,80 +345,30 @@ class APISIXSSLManager:
logger.error(f"无法读取证书文件: {domain}") logger.error(f"无法读取证书文件: {domain}")
return False return False
else: else:
# 检查错误类型
error_output = result.stderr or ""
stdout_output = result.stdout or ""
combined_output = error_output + stdout_output
# 检查是否是速率限制错误
is_rate_limit = "too many" in combined_output.lower() or "rate limit" in combined_output.lower() or "retry after" in combined_output.lower()
# 检查是否是网络超时错误 # 检查是否是网络超时错误
error_output = result.stderr or ""
is_timeout_error = "ReadTimeout" in error_output or "timed out" in error_output.lower() is_timeout_error = "ReadTimeout" in error_output or "timed out" in error_output.lower()
# 提取重试时间(如果有)
retry_after_timestamp = None
if is_rate_limit:
retry_match = re.search(r'retry after ([0-9-: ]+)', combined_output, re.IGNORECASE)
if retry_match:
try:
retry_time_str = retry_match.group(1).strip()
# 解析时间字符串格式2026-01-01 20:17:09 UTC
retry_datetime = datetime.strptime(retry_time_str, '%Y-%m-%d %H:%M:%S %Z')
retry_after_timestamp = retry_datetime.timestamp()
except Exception:
# 如果解析失败,默认等待 1 小时
retry_after_timestamp = time.time() + 3600
else:
# 如果没有找到具体时间,默认等待 1 小时
retry_after_timestamp = time.time() + 3600
logger.error(f"证书申请失败 (退出码: {result.returncode})")
if result.stdout:
logger.error(f"标准输出: {result.stdout}")
if result.stderr:
logger.error(f"错误输出: {result.stderr}")
# 处理速率限制错误(不应该重试,直接返回)
if is_rate_limit:
logger.error("=" * 60)
logger.error("⚠️ 遇到 Let's Encrypt 速率限制")
logger.error("=" * 60)
logger.error("可能的原因:")
logger.error("1. 在短时间内申请了太多证书")
logger.error("2. 多次验证失败(可能是 webroot 路由配置问题)")
logger.error("3. 达到了 Let's Encrypt 的速率限制")
if retry_after_timestamp:
retry_datetime = datetime.fromtimestamp(retry_after_timestamp)
logger.error(f"建议在以下时间后重试: {retry_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
else:
logger.error("建议等待 1 小时后再重试")
logger.error("")
logger.error("解决方案:")
logger.error("1. 检查 webroot 路由是否正确配置")
logger.error("2. 确保域名可以正常访问 /.well-known/acme-challenge/ 路径")
logger.error("3. 等待速率限制解除后再重试")
logger.error("4. 查看详细日志: /var/log/letsencrypt/letsencrypt.log")
logger.error("=" * 60)
# 返回特殊值,表示速率限制(需要外部处理)
raise RateLimitError(domain, retry_after_timestamp or (time.time() + 3600))
# 处理网络超时错误
if is_timeout_error and attempt < max_retries: if is_timeout_error and attempt < max_retries:
logger.warning(f"证书申请网络超时 (尝试 {attempt}/{max_retries}),将重试...") logger.warning(f"证书申请网络超时 (尝试 {attempt}/{max_retries}),将重试...")
continue continue
elif is_timeout_error: else:
logger.error("网络连接超时,可能的原因:") logger.error(f"证书申请失败 (退出码: {result.returncode})")
logger.error("1. 服务器无法访问 Let's Encrypt 服务器 (acme-staging-v02.api.letsencrypt.org 或 acme-v02.api.letsencrypt.org)") if result.stdout:
logger.error("2. 防火墙阻止了 HTTPS 连接") logger.error(f"标准输出: {result.stdout}")
logger.error("3. 网络不稳定,建议稍后重试") if result.stderr:
logger.error("4. 可以检查网络连接: curl -I https://acme-staging-v02.api.letsencrypt.org/directory") logger.error(f"错误输出: {result.stderr}")
# 如果是网络超时且已尝试所有次数,给出提示
if is_timeout_error:
logger.error("网络连接超时,可能的原因:")
logger.error("1. 服务器无法访问 Let's Encrypt 服务器 (acme-staging-v02.api.letsencrypt.org 或 acme-v02.api.letsencrypt.org)")
logger.error("2. 防火墙阻止了 HTTPS 连接")
logger.error("3. 网络不稳定,建议稍后重试")
logger.error("4. 可以检查网络连接: curl -I https://acme-staging-v02.api.letsencrypt.org/directory")
return False
return False
except RateLimitError:
# 速率限制错误不应该重试,直接抛出
raise
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
if attempt < max_retries: if attempt < max_retries:
logger.warning(f"证书申请超时 (尝试 {attempt}/{max_retries}),将重试...") logger.warning(f"证书申请超时 (尝试 {attempt}/{max_retries}),将重试...")