""" Nginx 配置管理服务 负责读写 Nginx 配置文件、重载 Nginx 全局优化(在 /etc/nginx/nginx.conf http 块中): - gzip + gzip_types(14种MIME)— 压缩传输 - SSL session cache (10m) + timeout (60m) — 减少重复TLS握手 - ssl_session_tickets off + ssl_buffer_size 4k 每个域名配置的优化: - listen 443 ssl http2 — HTTP/2 多路复用 - proxy_buffering off — 边收边发,不阻塞 - 静态资源 30天强缓存(public, immutable) """ import os import re import subprocess import logging logger = logging.getLogger(__name__) # Nginx HTTP 配置模板 HTTP_TEMPLATE = """# Nginx转发规则 - {domain} # 协议: HTTP # 自动生成,请勿手动修改 server {{ listen 80; server_name {domain}; client_max_body_size 2048m; # 静态资源缓存(JS/CSS 带 hash,30天) location ~* \\.(js|css|svg|woff2|png|jpg|ico)$ {{ expires 30d; add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always; add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always; add_header Cache-Control \"public, immutable\"; proxy_pass {target}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection \"\"; proxy_set_header Accept-Encoding \"gzip\"; proxy_connect_timeout 43200s; proxy_send_timeout 43200s; proxy_read_timeout 43200s; proxy_buffering off; }} location / {{ add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always; add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always; proxy_pass {target}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection \"\"; proxy_set_header Accept-Encoding \"gzip\"; proxy_connect_timeout 43200s; proxy_send_timeout 43200s; proxy_read_timeout 43200s; proxy_buffering off; }} }} """ # Nginx HTTPS 配置模板 HTTPS_TEMPLATE = """# Nginx转发规则 - {domain} # 协议: HTTPS # 自动生成,请勿手动修改 # HTTP → HTTPS 重定向 server {{ listen 80; server_name {domain}; return 301 https://$host$request_uri; }} # HTTPS 服务 server {{ listen 443 ssl http2; server_name {domain}; # CORS 跨域响应头(server 级别,确保 OPTIONS 204 也带 CORS 头) add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always; add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always; # CORS 预检请求快速返回(避免穿透到后端) if ($request_method = OPTIONS) {{ return 204; }} ssl_certificate {ssl_cert}; ssl_certificate_key {ssl_key}; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; # 静态资源缓存(JS/CSS 带 hash,30天) location ~* \\.(js|css|svg|woff2|png|jpg|ico)$ {{ expires 30d; add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always; add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always; add_header Cache-Control \"public, immutable\"; proxy_pass {target}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection \"\"; proxy_set_header Accept-Encoding \"gzip\"; proxy_connect_timeout 43200s; proxy_send_timeout 43200s; proxy_read_timeout 43200s; proxy_buffering off; }} location / {{ add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always; add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always; proxy_pass {target}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection \"\"; proxy_set_header Accept-Encoding \"gzip\"; proxy_connect_timeout 43200s; proxy_send_timeout 43200s; proxy_read_timeout 43200s; proxy_buffering off; }} }} """ class NginxService: """Nginx 配置管理服务""" def __init__(self, conf_dir='/etc/nginx/conf.d', ssl_dir='/etc/nginx/ssl'): self.conf_dir = conf_dir self.ssl_dir = ssl_dir # 确保目录存在 os.makedirs(self.conf_dir, exist_ok=True) def create_config(self, domain, protocol, target, ssl_cert=None, ssl_key=None): """ 创建域名转发配置文件 Args: domain: 域名 protocol: 协议 (http/https) target: 转发目标地址 ssl_cert: SSL证书路径(仅在HTTPS时需要) ssl_key: SSL私钥路径(仅在HTTPS时需要) Returns: config_file: 生成的配置文件路径 """ config_path = os.path.join(self.conf_dir, f'{domain}.conf') if protocol == 'https': content = HTTPS_TEMPLATE.format( domain=domain, target=target, ssl_cert=ssl_cert or f'{self.ssl_dir}/{domain}.pem', ssl_key=ssl_key or f'{self.ssl_dir}/{domain}.key' ) else: content = HTTP_TEMPLATE.format( domain=domain, target=target ) with open(config_path, 'w', encoding='utf-8') as f: f.write(content) logger.info(f"已创建配置文件: {config_path}") return config_path def delete_config(self, domain): """ 删除域名配置文件 Args: domain: 域名 Returns: bool: 是否成功删除 """ config_path = os.path.join(self.conf_dir, f'{domain}.conf') if os.path.exists(config_path): os.remove(config_path) logger.info(f"已删除配置文件: {config_path}") return True logger.warning(f"配置文件不存在: {config_path}") return False def list_rules(self): """ 解析所有 Nginx 配置文件,提取转发规则列表 Returns: list[dict]: 规则列表 """ rules = [] if not os.path.exists(self.conf_dir): return rules for filename in sorted(os.listdir(self.conf_dir)): if not filename.endswith('.conf'): continue filepath = os.path.join(self.conf_dir, filename) rule = self._parse_config(filepath) if rule: rules.append(rule) return rules def find_rule(self, domain): """ 查找指定域名的规则 Args: domain: 域名 Returns: dict | None: 规则信息 """ config_path = os.path.join(self.conf_dir, f'{domain}.conf') if not os.path.exists(config_path): return None return self._parse_config(config_path) def _parse_config(self, filepath): """ 解析单个 Nginx 配置文件 Args: filepath: 配置文件路径 Returns: dict | None: 解析出的规则信息 """ try: with open(filepath, 'r', encoding='utf-8') as f: content = f.read() rule = { 'config_file': filepath } # 提取域名 (server_name) server_name_match = re.search(r'server_name\s+([^;]+);', content) if server_name_match: rule['domain'] = server_name_match.group(1).strip() # 提取转发目标 (proxy_pass) proxy_pass_match = re.search(r'proxy_pass\s+([^;]+);', content) if proxy_pass_match: rule['target'] = proxy_pass_match.group(1).strip() # 判断协议类型 if 'listen 443 ssl' in content: rule['protocol'] = 'https' # 提取 SSL 证书路径 cert_match = re.search(r'ssl_certificate\s+([^;]+);', content) if cert_match: rule['ssl_cert'] = cert_match.group(1).strip() key_match = re.search(r'ssl_certificate_key\s+([^;]+);', content) if key_match: rule['ssl_key'] = key_match.group(1).strip() else: rule['protocol'] = 'http' return rule except Exception as e: logger.error(f"解析配置文件失败 {filepath}: {e}") return None def check_config(self): """ 检查 Nginx 配置语法 Returns: tuple: (是否通过, 错误信息) """ try: result = subprocess.run( ['nginx', '-t'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10 ) if result.returncode == 0: return True, 'ok' else: error_msg = result.stderr.strip() or '配置语法错误' return False, error_msg except FileNotFoundError: return False, 'nginx 命令未找到,请确认 Nginx 已安装' except subprocess.TimeoutExpired: return False, 'nginx -t 执行超时' except Exception as e: return False, str(e) def reload(self): """ 重载 Nginx 配置 Returns: tuple: (是否成功, 消息) """ # 先检查配置语法 valid, msg = self.check_config() if not valid: return False, f'配置语法检查失败: {msg}' # 执行 reload try: # 尝试 systemctl reload result = subprocess.run( ['systemctl', 'reload', 'nginx'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=30 ) if result.returncode == 0: logger.info("Nginx 重载成功 (systemctl)") return True, 'Nginx 重载成功' # 回退到 nginx -s reload result = subprocess.run( ['nginx', '-s', 'reload'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=30 ) if result.returncode == 0: logger.info("Nginx 重载成功 (nginx -s reload)") return True, 'Nginx 重载成功' error_msg = result.stderr.strip() or '未知错误' return False, f'重载失败: {error_msg}' except FileNotFoundError: return False, 'nginx 命令未找到' except subprocess.TimeoutExpired: return False, 'reload 执行超时' except Exception as e: return False, str(e) def parse_gzip_config(self, domain): """ 从配置文件中解析 gzip 压缩配置 Args: domain: 域名 Returns: dict: gzip 配置信息 """ config_path = os.path.join(self.conf_dir, f'{domain}.conf') if not os.path.exists(config_path): return { 'enabled': False, 'types': [], 'comp_level': 6, 'min_length': '1000' } with open(config_path, 'r', encoding='utf-8') as f: content = f.read() result = { 'enabled': False, 'types': [], 'comp_level': 6, 'min_length': '1000' } # 解析 gzip on/off gzip_match = re.search(r'^\s*gzip\s+(on|off)\s*;', content, re.MULTILINE) if gzip_match: result['enabled'] = gzip_match.group(1) == 'on' # 解析 gzip_types types_match = re.search(r'^\s*gzip_types\s+([^;]+);', content, re.MULTILINE) if types_match: types_str = types_match.group(1).strip() result['types'] = [t.strip() for t in types_str.split() if t.strip()] # 解析 gzip_comp_level level_match = re.search(r'^\s*gzip_comp_level\s+(\d+)\s*;', content, re.MULTILINE) if level_match: result['comp_level'] = int(level_match.group(1)) # 解析 gzip_min_length length_match = re.search(r'^\s*gzip_min_length\s+(\S+)\s*;', content, re.MULTILINE) if length_match: result['min_length'] = length_match.group(1).strip() return result def set_gzip_config(self, domain, gzip_config): """ 修改配置文件中的 gzip 压缩配置(在 server 块内添加/更新 gzip 指令) Args: domain: 域名 gzip_config: dict with keys: - enabled (bool): 是否开启 - types (list[str]): MIME 类型列表 - comp_level (int): 压缩级别 1-9 - min_length (str): 最小压缩长度 Returns: tuple: (成功, 消息) """ config_path = os.path.join(self.conf_dir, f'{domain}.conf') if not os.path.exists(config_path): return False, f'域名 {domain} 的配置文件不存在' # 读取原内容 with open(config_path, 'r', encoding='utf-8') as f: original_content = f.read() content = original_content # 1. 删除 server 块内所有已有的 gzip 指令(包括注释行) content = re.sub(r'^\s*#\s*内容压缩[^\n]*\n?', '', content, flags=re.MULTILINE) content = re.sub(r'^\s*gzip\s+(on|off)\s*;\s*\n?', '', content, flags=re.MULTILINE) content = re.sub(r'^\s*gzip_types\s+[^;]+;\s*\n?', '', content, flags=re.MULTILINE) content = re.sub(r'^\s*gzip_comp_level\s+\d+\s*;\s*\n?', '', content, flags=re.MULTILINE) content = re.sub(r'^\s*gzip_min_length\s+\S+\s*;\s*\n?', '', content, flags=re.MULTILINE) # 2. 构建新的 gzip 指令块 gzip_lines = [] if gzip_config.get('enabled', False): gzip_lines.append(' # 内容压缩(gzip)') gzip_lines.append(' gzip on;') types = gzip_config.get('types', []) if types: types_str = ' '.join(types) gzip_lines.append(f' gzip_types {types_str};') comp_level = gzip_config.get('comp_level', 6) gzip_lines.append(f' gzip_comp_level {comp_level};') min_length = gzip_config.get('min_length', '1000') gzip_lines.append(f' gzip_min_length {min_length};') else: gzip_lines.append(' # 内容压缩(gzip)') gzip_lines.append(' gzip off;') gzip_block = '\n'.join(gzip_lines) + '\n' # 3. 在 server_name 行之后插入 gzip 指令块 # 匹配 server_name xxx; 并在其后插入 def insert_gzip(match): return match.group(0) + '\n' + gzip_block content = re.sub( r'^\s*server_name\s+[^;]+;\s*$', insert_gzip, content, flags=re.MULTILINE ) # 4. 写入文件 with open(config_path, 'w', encoding='utf-8') as f: f.write(content) logger.info(f"已更新 {domain} 的 gzip 压缩配置: enabled={gzip_config.get('enabled')}") # 5. 语法检查 valid, msg = self.check_config() if not valid: # 回滚 with open(config_path, 'w', encoding='utf-8') as f: f.write(original_content) logger.error(f"gzip 配置语法错误,已回滚: {msg}") return False, f'配置语法检查失败,已自动回滚: {msg}' # 6. 重载 Nginx reload_success, reload_msg = self.reload() if not reload_success: logger.warning(f"Nginx 重载失败: {reload_msg}") return False, f'gzip 配置已写入但重载失败: {reload_msg}' return True, 'gzip 压缩配置已保存并生效' def get_domain_logs(self, domain, lines=200): """ 获取指定域名的 Nginx 访问日志和错误日志 Args: domain: 域名 lines: 返回最近多少行日志(默认200) Returns: dict: access_log 和 error_log 内容 """ result = { 'domain': domain, 'access_log': '', 'error_log': '', 'access_log_path': '/var/log/nginx/access.log', 'error_log_path': '/var/log/nginx/error.log' } # 访问日志 — 过滤包含域名的行 access_log_path = '/var/log/nginx/access.log' if os.path.exists(access_log_path): try: # 使用 tail + grep 获取最近包含域名的日志行 import subprocess as _sp proc = _sp.run( ['tail', '-n', str(lines * 5), access_log_path], stdout=_sp.PIPE, stderr=_sp.PIPE, universal_newlines=True, timeout=10 ) if proc.returncode == 0: all_lines = proc.stdout.splitlines() matched = [l for l in all_lines if domain in l] result['access_log'] = '\n'.join(matched[-lines:]) except Exception as e: logger.warning(f"读取访问日志失败: {e}") result['access_log'] = f'[读取失败] {e}' # 错误日志 — 过滤包含域名的行 error_log_path = '/var/log/nginx/error.log' if os.path.exists(error_log_path): try: import subprocess as _sp proc = _sp.run( ['tail', '-n', str(lines * 5), error_log_path], stdout=_sp.PIPE, stderr=_sp.PIPE, universal_newlines=True, timeout=10 ) if proc.returncode == 0: all_lines = proc.stdout.splitlines() matched = [l for l in all_lines if domain in l] result['error_log'] = '\n'.join(matched[-lines:]) except Exception as e: logger.warning(f"读取错误日志失败: {e}") result['error_log'] = f'[读取失败] {e}' return result def _escape_nginx(content): """转义 Nginx 配置中的特殊字符(模板用)""" return content