568 lines
19 KiB
Python
568 lines
19 KiB
Python
"""
|
||
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
|