初始化1
This commit is contained in:
567
nginx-python-api/services/nginx_service.py
Normal file
567
nginx-python-api/services/nginx_service.py
Normal file
@@ -0,0 +1,567 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user