初始化1
This commit is contained in:
532
nginx-python-api/services/ssl_service.py
Normal file
532
nginx-python-api/services/ssl_service.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
SSL 证书管理服务
|
||||
支持两种证书签发方式:
|
||||
- 内部自建 CA → 手动创建的内网域名
|
||||
- Let's Encrypt DNS-01 → 系统内置域名 (zgitm.com)
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 证书有效期(天)
|
||||
CERT_VALIDITY_DAYS = 365
|
||||
|
||||
# ─── Let's Encrypt 系统域名配置 ───────────────────
|
||||
# zgitm.com 及其所有子域名使用 Let's Encrypt 证书
|
||||
LE_ROOT_DOMAIN = 'zgitm.com'
|
||||
ACME_SH = '/root/.acme.sh/acme.sh'
|
||||
LE_STAGING = os.environ.get('LE_STAGING', '0') == '1' # 1=测试环境
|
||||
|
||||
# OpenSSL 配置文件模板(用于签发域名证书)
|
||||
OPENSSL_CNF_TEMPLATE = """[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = req_ext
|
||||
|
||||
[dn]
|
||||
CN = {domain}
|
||||
O = Mokee Internal
|
||||
OU = Nginx Manager
|
||||
|
||||
[req_ext]
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = {domain}
|
||||
"""
|
||||
|
||||
|
||||
class SSLService:
|
||||
"""SSL 证书管理服务"""
|
||||
|
||||
def __init__(self, ca_dir='/etc/nginx/ssl/ca', ssl_dir='/etc/nginx/ssl'):
|
||||
self.ca_dir = ca_dir
|
||||
self.ssl_dir = ssl_dir
|
||||
self.ca_key = os.path.join(ca_dir, 'ca.key')
|
||||
self.ca_crt = os.path.join(ca_dir, 'ca.crt')
|
||||
self.ca_srl = os.path.join(ca_dir, 'ca.srl')
|
||||
|
||||
def init_ca(self):
|
||||
"""
|
||||
初始化内部 CA
|
||||
首次运行时自动生成根证书,之后重复使用
|
||||
"""
|
||||
os.makedirs(self.ca_dir, exist_ok=True)
|
||||
|
||||
if os.path.exists(self.ca_key) and os.path.exists(self.ca_crt):
|
||||
logger.info("内部 CA 已存在,跳过初始化")
|
||||
return
|
||||
|
||||
logger.info("正在生成内部根 CA...")
|
||||
|
||||
# 生成 CA 私钥
|
||||
subprocess.run([
|
||||
'openssl', 'genrsa',
|
||||
'-out', self.ca_key,
|
||||
'2048'
|
||||
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# 生成自签名 CA 证书
|
||||
subprocess.run([
|
||||
'openssl', 'req',
|
||||
'-x509',
|
||||
'-new',
|
||||
'-nodes',
|
||||
'-key', self.ca_key,
|
||||
'-sha256',
|
||||
'-days', str(CERT_VALIDITY_DAYS * 10), # CA 有效期 10 年
|
||||
'-out', self.ca_crt,
|
||||
'-subj', '/CN=Mokee Internal CA/O=Mokee/OU=Nginx Manager'
|
||||
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# 设置适当的权限
|
||||
os.chmod(self.ca_key, 0o600)
|
||||
os.chmod(self.ca_crt, 0o644)
|
||||
|
||||
logger.info(f"内部 CA 已生成: {self.ca_crt}")
|
||||
logger.info("客户端需要信任此 CA 证书才能避免浏览器警告")
|
||||
|
||||
def ensure_certificate(self, domain):
|
||||
"""
|
||||
确保域名拥有有效的 SSL 证书
|
||||
如果不存在则自动签发
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
tuple: (证书路径, 私钥路径)
|
||||
"""
|
||||
cert_path = os.path.join(self.ssl_dir, f'{domain}.pem')
|
||||
key_path = os.path.join(self.ssl_dir, f'{domain}.key')
|
||||
|
||||
# 确保证书目录存在
|
||||
os.makedirs(self.ssl_dir, exist_ok=True)
|
||||
|
||||
# 如果证书已存在且未过期,直接复用
|
||||
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
if self._is_cert_valid(cert_path):
|
||||
logger.info(f"域名 {domain} 的证书已存在且有效,复用")
|
||||
return cert_path, key_path
|
||||
else:
|
||||
logger.info(f"域名 {domain} 的证书已过期,重新签发")
|
||||
|
||||
# 签发新证书
|
||||
self._issue_certificate(domain, cert_path, key_path)
|
||||
return cert_path, key_path
|
||||
|
||||
def _issue_certificate(self, domain, cert_path, key_path):
|
||||
"""
|
||||
使用内部 CA 签发域名证书
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
cert_path: 证书输出路径
|
||||
key_path: 私钥输出路径
|
||||
"""
|
||||
# 确保证书目录已创建
|
||||
os.makedirs(self.ssl_dir, exist_ok=True)
|
||||
|
||||
# 创建临时 OpenSSL 配置文件
|
||||
cnf_path = os.path.join(self.ssl_dir, f'{domain}.cnf')
|
||||
with open(cnf_path, 'w') as f:
|
||||
f.write(OPENSSL_CNF_TEMPLATE.format(domain=domain))
|
||||
|
||||
try:
|
||||
# 生成域名私钥
|
||||
subprocess.run([
|
||||
'openssl', 'genrsa',
|
||||
'-out', key_path,
|
||||
'2048'
|
||||
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# 生成 CSR 并使用 CA 签发
|
||||
subprocess.run([
|
||||
'openssl', 'req',
|
||||
'-new',
|
||||
'-key', key_path,
|
||||
'-out', os.path.join(self.ssl_dir, f'{domain}.csr'),
|
||||
'-config', cnf_path
|
||||
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# 使用 CA 签发证书
|
||||
subprocess.run([
|
||||
'openssl', 'x509',
|
||||
'-req',
|
||||
'-in', os.path.join(self.ssl_dir, f'{domain}.csr'),
|
||||
'-CA', self.ca_crt,
|
||||
'-CAkey', self.ca_key,
|
||||
'-CAcreateserial',
|
||||
'-out', cert_path,
|
||||
'-days', str(CERT_VALIDITY_DAYS),
|
||||
'-sha256',
|
||||
'-extensions', 'req_ext',
|
||||
'-extfile', cnf_path
|
||||
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# 设置权限
|
||||
os.chmod(key_path, 0o600)
|
||||
os.chmod(cert_path, 0o644)
|
||||
|
||||
# 清理临时文件
|
||||
csr_path = os.path.join(self.ssl_dir, f'{domain}.csr')
|
||||
if os.path.exists(csr_path):
|
||||
os.remove(csr_path)
|
||||
if os.path.exists(cnf_path):
|
||||
os.remove(cnf_path)
|
||||
|
||||
logger.info(f"已为域名 {domain} 签发 SSL 证书: {cert_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"签发证书失败: {e.stderr.decode() if e.stderr else str(e)}")
|
||||
raise RuntimeError(f"签发SSL证书失败: {e}")
|
||||
except Exception as e:
|
||||
# 清理可能残留的临时文件
|
||||
for tmp in [cnf_path, os.path.join(self.ssl_dir, f'{domain}.csr')]:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
def _is_cert_valid(self, cert_path):
|
||||
"""
|
||||
检查证书是否有效且未过期
|
||||
|
||||
Args:
|
||||
cert_path: 证书文件路径
|
||||
|
||||
Returns:
|
||||
bool: 是否有效
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run([
|
||||
'openssl', 'x509',
|
||||
'-in', cert_path,
|
||||
'-noout',
|
||||
'-enddate'
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=5)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
# 解析过期日期
|
||||
# 输出格式: notAfter=Dec 31 23:59:59 2025 GMT
|
||||
output = result.stdout.strip()
|
||||
if 'notAfter=' in output:
|
||||
date_str = output.split('notAfter=')[1].strip()
|
||||
expire_date = datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z')
|
||||
return datetime.now() < expire_date
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"检查证书有效性失败: {e}")
|
||||
return False
|
||||
|
||||
def get_cert_info(self, domain):
|
||||
"""
|
||||
获取域名证书信息
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
dict | None: 证书信息
|
||||
"""
|
||||
cert_path = os.path.join(self.ssl_dir, f'{domain}.pem')
|
||||
key_path = os.path.join(self.ssl_dir, f'{domain}.key')
|
||||
|
||||
if not os.path.exists(cert_path):
|
||||
return None
|
||||
|
||||
info = {
|
||||
'domain': domain,
|
||||
'cert_path': cert_path,
|
||||
'key_path': key_path,
|
||||
'valid': self._is_cert_valid(cert_path)
|
||||
}
|
||||
|
||||
# 获取过期日期
|
||||
try:
|
||||
result = subprocess.run([
|
||||
'openssl', 'x509',
|
||||
'-in', cert_path,
|
||||
'-noout',
|
||||
'-enddate'
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=5)
|
||||
if result.returncode == 0 and 'notAfter=' in result.stdout:
|
||||
info['expire_date'] = result.stdout.split('notAfter=')[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def revoke_certificate(self, domain):
|
||||
"""
|
||||
删除域名证书(软删除,实际保留文件但标记无效)
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
cert_path = os.path.join(self.ssl_dir, f'{domain}.pem')
|
||||
key_path = os.path.join(self.ssl_dir, f'{domain}.key')
|
||||
|
||||
removed = False
|
||||
for path in [cert_path, key_path]:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
logger.info(f"已删除: {path}")
|
||||
removed = True
|
||||
|
||||
return removed
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Let's Encrypt 证书管理(acme.sh + 阿里云 DNS)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@staticmethod
|
||||
def is_zgitm_domain(domain):
|
||||
"""判断域名是否属于 zgitm.com(含所有子域名),应使用 Let's Encrypt 证书"""
|
||||
return domain == LE_ROOT_DOMAIN or domain.endswith('.' + LE_ROOT_DOMAIN)
|
||||
|
||||
@staticmethod
|
||||
def is_system_domain(domain):
|
||||
"""判断是否为系统保护的根域名(zgitm.com / www.zgitm.com),不可在页面创建"""
|
||||
return domain == LE_ROOT_DOMAIN or domain == 'www.' + LE_ROOT_DOMAIN
|
||||
|
||||
@staticmethod
|
||||
def get_le_root_domain():
|
||||
"""获取 Let's Encrypt 根域名"""
|
||||
return LE_ROOT_DOMAIN
|
||||
|
||||
def ensure_le_certificate(self, domain):
|
||||
"""
|
||||
确保 Let's Encrypt 证书存在且有效
|
||||
- 已有有效证书 → 直接返回路径
|
||||
- 证书不存在或过期 → 调用 acme.sh 签发
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
tuple: (证书路径, 私钥路径) 或 (None, None) 失败时
|
||||
"""
|
||||
cert_path = os.path.join(self.ssl_dir, f'{domain}.pem')
|
||||
key_path = os.path.join(self.ssl_dir, f'{domain}.key')
|
||||
|
||||
os.makedirs(self.ssl_dir, exist_ok=True)
|
||||
|
||||
# 已有有效证书,直接复用
|
||||
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
if self._is_cert_valid(cert_path):
|
||||
logger.info(f"LE 证书已存在且有效: {domain}")
|
||||
return cert_path, key_path
|
||||
else:
|
||||
logger.info(f"LE 证书已过期,重新签发: {domain}")
|
||||
|
||||
# 签发新证书
|
||||
success = self._issue_le_certificate(domain)
|
||||
if success:
|
||||
return cert_path, key_path
|
||||
return None, None
|
||||
|
||||
def _issue_le_certificate(self, domain):
|
||||
"""
|
||||
使用 acme.sh DNS-01 方式签发 Let's Encrypt 证书
|
||||
通过阿里云 DNS API 自动添加 TXT 验证记录
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
# 读取阿里云凭据(从环境变量,安全敏感信息不能硬编码)
|
||||
ali_key = os.environ.get('Ali_Key', '')
|
||||
ali_secret = os.environ.get('Ali_Secret', '')
|
||||
|
||||
if not ali_key or not ali_secret:
|
||||
logger.error("缺少阿里云 AccessKey 环境变量 Ali_Key / Ali_Secret")
|
||||
return False
|
||||
|
||||
logger.info(f"开始签发 LE 证书: {domain}")
|
||||
|
||||
try:
|
||||
# Step 1: acme.sh 签发(DNS-01 挑战)
|
||||
# acme.sh DNS-01 签发(env vars 从 systemd 继承)
|
||||
log_file = f'/tmp/acme_{domain}.log'
|
||||
args = [ACME_SH, '--issue', '--dns', 'dns_ali', '-d', domain,
|
||||
'-k', 'ec-256', '--home', '/root/.acme.sh',
|
||||
'--server', 'letsencrypt', '--log', log_file]
|
||||
if LE_STAGING:
|
||||
args.append('--staging')
|
||||
|
||||
result = subprocess.run(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
timeout=180
|
||||
)
|
||||
|
||||
combined_output = result.stdout + result.stderr
|
||||
if 'Cert success' not in combined_output:
|
||||
# 读取 acme.sh 详细日志
|
||||
try:
|
||||
with open(log_file, 'r') as f:
|
||||
debug_log = f.read()
|
||||
except Exception:
|
||||
debug_log = '(log file not found)'
|
||||
logger.error(f"acme.sh 签发失败 ({domain}): "
|
||||
f"rc={result.returncode} stdout={result.stdout[-200:]} "
|
||||
f"stderr={result.stderr[-200:]} log={debug_log[-500:]}")
|
||||
return False
|
||||
|
||||
logger.info(f"acme.sh 签发成功: {domain}")
|
||||
|
||||
# Step 2: 安装证书到 Nginx SSL 目录
|
||||
cert_file = os.path.join(self.ssl_dir, f'{domain}.pem')
|
||||
key_file = os.path.join(self.ssl_dir, f'{domain}.key')
|
||||
|
||||
install_result = subprocess.run(
|
||||
[ACME_SH, '--install-cert', '-d', domain,
|
||||
'--key-file', key_file, '--fullchain-file', cert_file,
|
||||
'--reloadcmd', 'systemctl reload nginx',
|
||||
'--home', '/root/.acme.sh'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, timeout=30)
|
||||
|
||||
if install_result.returncode != 0:
|
||||
logger.error(f"安装证书失败: {install_result.stderr[-300:]}")
|
||||
return False
|
||||
|
||||
# 设置权限
|
||||
os.chmod(key_file, 0o600)
|
||||
os.chmod(cert_file, 0o644)
|
||||
|
||||
logger.info(f"LE 证书已安装: {cert_file}")
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"acme.sh 签发超时 (180s): {domain}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"签发 LE 证书异常: {e}")
|
||||
return False
|
||||
|
||||
def renew_le_certificate(self, domain):
|
||||
"""
|
||||
手动续期 Let's Encrypt 证书
|
||||
acme.sh 有内置 cron 自动续期,此方法用于手动触发
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
dict: {success: bool, message: str, log: str}
|
||||
"""
|
||||
logger.info(f"手动续期 LE 证书: {domain}")
|
||||
|
||||
try:
|
||||
ali_key = os.environ.get('Ali_Key', '')
|
||||
ali_secret = os.environ.get('Ali_Secret', '')
|
||||
|
||||
args = [ACME_SH, '--renew', '-d', domain, '--force',
|
||||
'--home', '/root/.acme.sh', '--server', 'letsencrypt']
|
||||
if LE_STAGING:
|
||||
args.append('--staging')
|
||||
|
||||
result = subprocess.run(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
timeout=180
|
||||
)
|
||||
|
||||
output = (result.stdout + result.stderr)[-2000:]
|
||||
success = 'Cert success' in (result.stdout + result.stderr)
|
||||
|
||||
if success:
|
||||
logger.info(f"续期成功: {domain}")
|
||||
# 更新证书文件权限
|
||||
cert_file = os.path.join(self.ssl_dir, f'{domain}.pem')
|
||||
key_file = os.path.join(self.ssl_dir, f'{domain}.key')
|
||||
if os.path.exists(cert_file):
|
||||
os.chmod(cert_file, 0o644)
|
||||
if os.path.exists(key_file):
|
||||
os.chmod(key_file, 0o600)
|
||||
return {'success': True, 'message': f'{domain} 续期成功', 'log': output}
|
||||
else:
|
||||
logger.error(f"续期失败: {domain}\n{output}")
|
||||
return {'success': False, 'message': f'{domain} 续期失败', 'log': output}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'success': False, 'message': '续期超时 (180s)', 'log': ''}
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': str(e), 'log': ''}
|
||||
|
||||
def get_le_cert_info(self, domain):
|
||||
"""
|
||||
获取 Let's Encrypt 证书详细信息
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
dict: 证书信息,包含过期日期、剩余天数等
|
||||
"""
|
||||
cert_path = os.path.join(self.ssl_dir, f'{domain}.pem')
|
||||
|
||||
if not os.path.exists(cert_path):
|
||||
return {'domain': domain, 'exists': False, 'error': '证书文件不存在'}
|
||||
|
||||
info = {
|
||||
'domain': domain,
|
||||
'exists': True,
|
||||
'cert_path': cert_path,
|
||||
'valid': self._is_cert_valid(cert_path)
|
||||
}
|
||||
|
||||
# 获取过期日期
|
||||
try:
|
||||
result = subprocess.run([
|
||||
'openssl', 'x509', '-in', cert_path, '-noout', '-enddate'
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, timeout=10)
|
||||
if 'notAfter=' in result.stdout:
|
||||
info['expire_date_str'] = result.stdout.split('notAfter=')[1].strip()
|
||||
expire_date = datetime.strptime(info['expire_date_str'], '%b %d %H:%M:%S %Y %Z')
|
||||
info['expire_date'] = expire_date.isoformat()
|
||||
info['days_left'] = (expire_date - datetime.now()).days
|
||||
info['expiring_soon'] = 0 <= info['days_left'] <= 30
|
||||
except Exception as e:
|
||||
info['parse_error'] = str(e)
|
||||
|
||||
# 获取签发者
|
||||
try:
|
||||
result = subprocess.run([
|
||||
'openssl', 'x509', '-in', cert_path, '-noout', '-issuer'
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, timeout=10)
|
||||
info['issuer'] = result.stdout.strip().replace('issuer=', '')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取主题
|
||||
try:
|
||||
result = subprocess.run([
|
||||
'openssl', 'x509', '-in', cert_path, '-noout', '-subject'
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, timeout=10)
|
||||
info['subject'] = result.stdout.strip().replace('subject=', '')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
Reference in New Issue
Block a user