Files
nginxserver/nginx-python-api/services/ssl_service.py
2026-07-16 13:03:11 +08:00

533 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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