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

568 lines
19 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.
"""
Nginx 配置管理服务
负责读写 Nginx 配置文件、重载 Nginx
全局优化(在 /etc/nginx/nginx.conf http 块中):
- gzip + gzip_types14种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 带 hash30天
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 带 hash30天
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