""" 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