初始化1

This commit is contained in:
zg
2026-07-16 13:03:11 +08:00
parent 0bdfcbc1c8
commit e6b0f287cc
100 changed files with 12184 additions and 0 deletions

View 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