diff --git a/.gitignore b/.gitignore index 3fb3d09..04f1c80 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ test/ hs_err_pid* replay_pid* +.idea \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..130c3ca --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,519 @@ +# Nginx域名转发管理平台 - 部署文档 + +日期: 2026-06-09 | 服务器: Rocky Linux 8.10 (10.1.1.160) + +--- + +## 一、服务器环境信息 + +### 1.1 10.1.1.160 - Nginx 专用服务器 + +| 项目 | 详情 | +|------|------| +| 操作系统 | Rocky Linux 8.10 (Green Obsidian) | +| 内核版本 | 4.18.0-553.el8_10.x86_64 | +| 主机名 | linuxNginx | +| SSH | root@10.1.1.160:22 | +| Nginx | 1.14.1 | +| Python | 3.6.8 | +| OpenSSL | 1.1.1k FIPS | + +### 1.2 监听端口 + +| 端口 | 服务 | 用途 | +|------|------|------| +| 22 | sshd | SSH远程管理 | +| 80 | nginx | HTTP转发入口 | +| 443 | nginx | HTTPS转发入口(按需) | +| 5000 | gunicorn | Nginx管理API | + +### 1.3 外部依赖 + +| 服务 | 地址 | 用途 | +|------|------|------| +| MySQL | 10.1.1.122:3306 | 数据库 (nginxserver库) | + +--- + +## 二、10.1.1.160 上执行的全部命令 + +### 2.1 安装系统依赖 + +```bash +# 安装 Nginx + Python3 + OpenSSL +dnf install -y nginx python3 python3-pip openssl + +# 安装 Python 依赖包 +pip3 install flask gunicorn +``` + +### 2.2 创建目录结构 + +```bash +# 项目代码目录 +mkdir -p /opt/nginx-api/services + +# Nginx 配置与证书目录 +mkdir -p /etc/nginx/conf.d +mkdir -p /etc/nginx/ssl/ca +``` + +### 2.3 部署代码文件 + +将本地 nginx-python-api/ 目录下文件上传到 /opt/nginx-api/: + +| 本地文件 | 服务器路径 | 权限 | +|----------|-----------|------| +| app.py | /opt/nginx-api/app.py | 755 | +| services/__init__.py | /opt/nginx-api/services/__init__.py | 644 | +| services/nginx_service.py | /opt/nginx-api/services/nginx_service.py | 644 | +| services/ssl_service.py | /opt/nginx-api/services/ssl_service.py | 644 | +| requirements.txt | /opt/nginx-api/requirements.txt | 644 | +| gunicorn_config.py | /opt/nginx-api/gunicorn_config.py | 755 | +| deploy.sh | /opt/nginx-api/deploy.sh | 755 | + +上传方式(使用 Python paramiko SFTP 或 scp): + +```bash +# 方式一: scp +scp -r nginx-python-api/* root@10.1.1.160:/opt/nginx-api/ + +# 方式二: Python paramiko SFTP +# 见项目根目录下 upload_api.py(已执行) +``` + +### 2.4 创建 systemd 服务文件 + +文件路径: /etc/systemd/system/nginx-api.service + +```ini +[Unit] +Description=Nginx Manager Python API +After=network.target nginx.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/nginx-api +ExecStart=/usr/bin/python3 -m gunicorn -c gunicorn_config.py app:app +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +创建命令: + +```bash +cat > /etc/systemd/system/nginx-api.service << 'EOF' +[Unit] +Description=Nginx Manager Python API +After=network.target nginx.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/nginx-api +ExecStart=/usr/bin/python3 -m gunicorn -c gunicorn_config.py app:app +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF +``` + +### 2.5 Gunicorn 配置文件 + +文件路径: /opt/nginx-api/gunicorn_config.py + +```python +import os + +# 监听地址和端口 +bind = "0.0.0.0:5000" + +# Worker 进程数 +workers = int(os.environ.get('GUNICORN_WORKERS', 2)) + +# Worker 类型 +worker_class = "sync" + +# 超时设置 +timeout = 60 +graceful_timeout = 10 + +# 日志 +accesslog = "-" # 标准输出 +errorlog = "-" # 标准错误 +loglevel = "info" + +# 进程名称 +proc_name = "nginx-manager-api" + +# 前台运行(由 systemd 管理) +daemon = False +``` + +### 2.6 防火墙配置 + +```bash +# 开放端口和服务 +firewall-cmd --permanent --add-port=5000/tcp +firewall-cmd --permanent --add-service=http +firewall-cmd --permanent --add-service=https +firewall-cmd --reload + +# 查看防火墙规则 +firewall-cmd --list-all +``` + +当前防火墙状态: + +``` +public (active) + target: default + interfaces: ens160 + services: cockpit dhcpv6-client http https ssh + ports: 5000/tcp +``` + +### 2.7 启动服务并设置开机自启 + +```bash +# 启动 Nginx +systemctl start nginx +systemctl enable nginx + +# 重载 systemd 配置 +systemctl daemon-reload + +# 启动 Python API +systemctl start nginx-api +systemctl enable nginx-api + +# 验证服务状态 +systemctl status nginx +systemctl status nginx-api +``` + +### 2.8 验证部署 + +```bash +# Python API 健康检查 +curl http://127.0.0.1:5000/api/nginx/health + +# 预期返回: +# {"code":0,"data":{"conf_dir":"/etc/nginx/conf.d","nginx_installed":true,"ssl_dir":"/etc/nginx/ssl"},"message":"Nginx Manager API is running"} +``` + +--- + +## 三、开机自启状态确认 + +| 服务 | systemd unit | 状态 | +|------|-------------|------| +| Nginx | nginx.service | enabled | +| Python API | nginx-api.service | enabled | +| SSHD | sshd.service | enabled | +| Firewalld | firewalld.service | enabled | + +查看命令: + +```bash +systemctl list-unit-files --state=enabled | grep -E "nginx|sshd|firewalld" +``` + +--- + +## 四、服务器目录结构 + +``` +/etc/nginx/ +├── nginx.conf # Nginx 主配置文件(系统默认) +├── conf.d/ # 转发规则配置目录 +│ └── .conf # 各域名的转发配置(由API自动生成) +├── ssl/ # SSL证书目录 +│ ├── ca/ # 内部根CA +│ │ ├── ca.crt # CA证书(有效期10年) +│ │ ├── ca.key # CA私钥(权限600) +│ │ └── ca.srl # 序列号文件 +│ ├── .pem # 域名SSL证书 +│ └── .key # 域名SSL私钥 + +/opt/nginx-api/ # Python API项目目录 +├── app.py # Flask主入口(7个API端点) +├── gunicorn_config.py # WSGI配置 +├── requirements.txt # Python依赖 +├── deploy.sh # 一键部署脚本 +└── services/ + ├── __init__.py + ├── nginx_service.py # Nginx配置读写 + reload + └── ssl_service.py # 内部CA + 证书签发 + +/etc/systemd/system/ +└── nginx-api.service # Python API 服务定义 +``` + +--- + +## 五、Python API 端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/nginx/health | 健康检查 | +| GET | /api/nginx/rules | 获取所有规则 | +| GET | /api/nginx/rules/ | 获取单个规则 | +| POST | /api/nginx/rules | 新增规则 | +| PUT | /api/nginx/rules/ | 修改规则 | +| DELETE | /api/nginx/rules/ | 删除规则 | +| POST | /api/nginx/reload | 重载Nginx | + +### 新增规则请求示例 + +```json +POST /api/nginx/rules +Content-Type: application/json + +{ + "domain": "test.mokee.com", + "protocol": "https", + "target": "http://10.1.1.11:8080" +} +``` + +### 成功响应 + +```json +{ + "code": 0, + "message": "规则创建成功", + "data": { + "domain": "test.mokee.com", + "protocol": "https", + "target": "http://10.1.1.11:8080", + "config_file": "/etc/nginx/conf.d/test.mokee.com.conf", + "ssl_cert": "/etc/nginx/ssl/test.mokee.com.pem", + "ssl_key": "/etc/nginx/ssl/test.mokee.com.key" + } +} +``` + +--- + +## 六、Nginx 配置模板(由 API 自动生成) + +### HTTP 转发 + +```nginx +# /etc/nginx/conf.d/.conf +server { + listen 80; + server_name ; + + location / { + proxy_pass ; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} +``` + +### HTTPS 转发 + +```nginx +# /etc/nginx/conf.d/.conf +server { + listen 80; + server_name ; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name ; + + ssl_certificate /etc/nginx/ssl/.pem; + ssl_certificate_key /etc/nginx/ssl/.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass ; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} +``` + +--- + +## 七、MySQL 数据库初始化 + +连接信息: + +| 项目 | 值 | +|------|-----| +| 地址 | 10.1.1.122:3306 | +| 数据库 | nginxserver | +| 用户 | nginxserver | + +初始化脚本: + +```bash +mysql -h 10.1.1.122 -u root -p < sql/init.sql +``` + +表结构: + +```sql +-- forwarding_rules 表 +CREATE TABLE forwarding_rules ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + domain VARCHAR(255) NOT NULL COMMENT '域名', + protocol VARCHAR(10) NOT NULL COMMENT '协议类型: http / https', + target VARCHAR(500) NOT NULL COMMENT '转发目标地址', + config_file VARCHAR(500) COMMENT 'Nginx配置文件路径', + ssl_cert VARCHAR(500) COMMENT 'SSL证书文件路径', + ssl_key VARCHAR(500) COMMENT 'SSL私钥文件路径', + status TINYINT DEFAULT 1 COMMENT '状态: 0-已删除, 1-正常', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_domain (domain), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Nginx转发规则表'; + +-- certificates 表 +CREATE TABLE certificates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + domain VARCHAR(255) NOT NULL COMMENT '域名', + cert_path VARCHAR(500) COMMENT '证书文件路径', + key_path VARCHAR(500) COMMENT '私钥文件路径', + issue_date DATETIME COMMENT '签发日期', + expire_date DATETIME COMMENT '过期日期', + status VARCHAR(20) DEFAULT 'active' COMMENT '状态', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_cert_domain (domain) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SSL证书管理表'; +``` + +--- + +## 八、HTTPS 证书方案 + +### 8.1 内部 CA + +- 首次启动 Python API 时自动生成(如不存在) +- CA 证书 /etc/nginx/ssl/ca/ca.crt,有效期 10 年 +- CA 私钥 /etc/nginx/ssl/ca/ca.key,权限 600 +- 不占用 80/443/5000 端口 + +### 8.2 域名证书自动签发 + +- 选择 HTTPS 协议时自动签发 +- 证书有效期 365 天 +- 已存在且有效的证书直接复用 +- 过期证书自动重新签发 + +### 8.3 客户端信任 + +内网用户需将 CA 证书导入浏览器: + +```bash +# 下载 CA 证书 +scp root@10.1.1.160:/etc/nginx/ssl/ca/ca.crt . +# 然后在浏览器中导入为受信任的根证书颁发机构 +``` + +--- + +## 九、常见运维命令 + +```bash +# === Nginx 管理 === +nginx -t # 检查配置语法 +nginx -s reload # 重载配置(不中断服务) +systemctl restart nginx # 重启 Nginx +systemctl status nginx # 查看状态 +tail -f /var/log/nginx/access.log # 查看访问日志 +tail -f /var/log/nginx/error.log # 查看错误日志 + +# === Python API 管理 === +systemctl status nginx-api # 查看服务状态 +systemctl restart nginx-api # 重启 API +journalctl -u nginx-api -f # 实时查看日志 +journalctl -u nginx-api -n 50 # 最近50行日志 + +# === API 测试 === +curl http://127.0.0.1:5000/api/nginx/health # 健康检查 +curl http://127.0.0.1:5000/api/nginx/rules # 查看规则列表 + +# === 防火墙管理 === +firewall-cmd --list-all # 查看防火墙规则 +firewall-cmd --add-port=5000/tcp # 临时开放端口 +firewall-cmd --runtime-to-permanent # 保存临时规则为永久 +``` + +--- + +## 十、Spring Boot 配置 + +配置文件: nginx-manager-backend/src/main/resources/application.yml + +```yaml +server: + port: 4002 + +spring: + datasource: + url: jdbc:mysql://10.1.1.122:3306/nginxserver?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: nginxserver + password: mokee. + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + +python-api: + base-url: http://10.1.1.160:5000 + connect-timeout: 5000 + read-timeout: 30000 +``` + +--- + +## 十一、Vue.js 前端配置 + +### 开发环境 (.env.development) + +``` +VITE_API_BASE_URL=http://localhost:4002 +VITE_APP_TITLE=Nginx转发管理平台 +``` + +### 生产环境 (.env.production) + +``` +VITE_API_BASE_URL=http://<生产服务器IP>:4002 +VITE_APP_TITLE=Nginx转发管理平台 +``` + +### 启动 + +```bash +npm run dev # 开发模式(端口3002) +npm run build # 生产构建 +``` diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..31def7a --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,270 @@ +# Nginx域名转发管理平台 - 实现方案文档 + +版本: 1.0 | 日期: 2026-06-09 | 状态: 已完成部署 + +--- + +## 一、项目概述 + +开发一个可视化管理平台,实现对 Nginx 反向代理规则的增删改查及 HTTPS 证书管理功能。 + +### 核心流程 + +``` +用户 -> Vue.js前端(:3002) -> Spring Boot(:4002) -> Python API(10.1.1.160:5000) -> Nginx + | + v + MySQL(10.1.1.122:3306) +``` + +--- + +## 二、技术架构 + +| 层级 | 技术 | 版本 | 端口 | 部署位置 | +|------|------|------|------|----------| +| 前端 | Vue.js 3 + Element Plus | Vite 8 | 3002 | 独立部署 | +| 后端 | Spring Boot + Maven | 3.2.0 + JDK 17 | 4002 | 独立部署 | +| 数据库 | MySQL | 8.0.29 | 3306 | 10.1.1.122 | +| Nginx API | Python Flask + Gunicorn | Flask 2.0.3 | 5000 | 10.1.1.160 | +| 代理服务器 | Nginx | 1.14.1 | 80/443 | 10.1.1.160 | + +### 关键设计决策 + +1. 三层架构 - 前端不直接调用 Python API,通过 Spring Boot 中间层统一管理 +2. 数据双写 - Python API 操作 Nginx 配置,Spring Boot 同步写入 MySQL 作为持久化 +3. 内部 CA - 内网域名无法使用 Let's Encrypt,采用自建 CA 自动签发证书 +4. 前后端直连 - 前端通过环境变量配置后端地址,不使用 Vite 代理 + +--- + +## 三、服务端点总览 + +### 3.1 Python API - 10.1.1.160:5000 + +| 方法 | 路径 | 功能 | +|------|------|------| +| GET | /api/nginx/health | 健康检查 | +| GET | /api/nginx/rules | 获取所有转发规则 | +| GET | /api/nginx/rules/ | 获取单个规则 | +| POST | /api/nginx/rules | 新增转发规则 | +| PUT | /api/nginx/rules/ | 修改转发规则 | +| DELETE | /api/nginx/rules/ | 删除转发规则 | +| POST | /api/nginx/reload | 重载 Nginx 配置 | + +### 3.2 Spring Boot - 端口 4002 + +| 方法 | 路径 | 功能 | +|------|------|------| +| GET | /api/rules | 获取规则列表(MySQL) | +| POST | /api/rules | 新增规则(调Python -> 存MySQL -> 重载) | +| PUT | /api/rules/{id} | 修改规则 | +| DELETE | /api/rules/{id} | 删除规则 | +| POST | /api/rules/reload | 手动重载 Nginx | +| GET | /api/rules/status | Nginx 运行状态 | + +### 3.3 Vue.js 前端 - 端口 3002 + +- 布局: 左侧深色菜单栏 + 顶部标题栏 + 内容区 +- 菜单: Nginx转发管理 +- 功能: 规则列表展示、新增/修改/删除弹窗、HTTPS 自动提示 +- 环境切换: .env.development / .env.production 配置不同后端地址 + +--- + +## 四、Nginx 转发规则逻辑 + +### HTTP 规则 + +``` +用户访问 http://test1.mokee.com + -> Nginx 80端口匹配 server_name test1.mokee.com + -> proxy_pass 转发到目标地址(如 http://10.1.1.11:8080) + -> 透传 Host/X-Real-IP/X-Forwarded-For 头 +``` + +### HTTPS 规则 + +``` +用户访问 https://test1.mokee.com + -> Nginx 80端口返回 301 跳转到 https:// + -> Nginx 443端口匹配 server_name,使用 SSL 证书 + -> proxy_pass 转发到目标地址 +``` + +### 生成的 Nginx 配置 + +#### HTTP 转发 + +```nginx +server { + listen 80; + server_name test1.mokee.com; + location / { + proxy_pass http://10.1.1.11:8080; + 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; + } +} +``` + +#### HTTPS 转发 + +```nginx +# HTTP 自动跳转 HTTPS +server { + listen 80; + server_name test1.mokee.com; + return 301 https://$host$request_uri; +} + +# HTTPS 转发 +server { + listen 443 ssl; + server_name test1.mokee.com; + + ssl_certificate /etc/nginx/ssl/test1.mokee.com.pem; + ssl_certificate_key /etc/nginx/ssl/test1.mokee.com.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass http://10.1.1.11:8080; + 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; + } +} +``` + +--- + +## 五、HTTPS 证书方案 + +### 5.1 内部自建 CA + +- 首次启动 Python API 时自动生成根 CA +- CA 证书: /etc/nginx/ssl/ca/ca.crt(有效期 10 年) +- CA 私钥: /etc/nginx/ssl/ca/ca.key(权限 600) +- 不占用 80/443/5000 端口 + +### 5.2 自动签发流程 + +``` +选择 HTTPS -> SSLService.ensure_certificate(domain) + -> 检查 /etc/nginx/ssl/.pem 是否存在且未过期 + -> 有效 -> 复用 + -> 无效/不存在 -> 使用内部 CA 签发新证书(有效期365天) +``` + +### 5.3 客户端信任 + +内网用户浏览器需导入 ca.crt 为受信任的根证书颁发机构: + +```bash +# 下载 CA 证书 +scp root@10.1.1.160:/etc/nginx/ssl/ca/ca.crt . +``` + +--- + +## 六、数据库设计 + +### forwarding_rules 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT PK | 主键 | +| domain | VARCHAR(255) UNIQUE | 域名 | +| protocol | VARCHAR(10) | http/https | +| target | VARCHAR(500) | 转发目标 | +| config_file | VARCHAR(500) | Nginx配置文件路径 | +| ssl_cert | VARCHAR(500) | SSL证书路径 | +| ssl_key | VARCHAR(500) | SSL私钥路径 | +| status | TINYINT | 0-已删除 1-正常 | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +### certificates 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT PK | 主键 | +| domain | VARCHAR(255) UNIQUE | 域名 | +| cert_path | VARCHAR(500) | 证书文件路径 | +| key_path | VARCHAR(500) | 私钥文件路径 | +| issue_date | DATETIME | 签发日期 | +| expire_date | DATETIME | 过期日期 | +| status | VARCHAR(20) | active/expired/revoked | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +--- + +## 七、项目文件结构 + +``` +nginxServer/ +├── nginx-python-api/ # Python Flask API 源码 +│ ├── app.py # 主入口(7个API端点) +│ ├── services/ +│ │ ├── nginx_service.py # Nginx配置CRUD + reload +│ │ └── ssl_service.py # 内部CA + 证书签发 +│ ├── requirements.txt # flask + gunicorn +│ ├── gunicorn_config.py # 生产环境配置 +│ └── deploy.sh # 一键部署脚本 +│ +├── nginx-manager-backend/ # Spring Boot 源码 +│ ├── pom.xml +│ └── src/main/java/com/mokee/nginx/ +│ ├── NginxManagerApplication.java +│ ├── controller/ +│ │ ├── ForwardingRuleController.java +│ │ └── GlobalExceptionHandler.java +│ ├── service/ +│ │ ├── ForwardingRuleService.java +│ │ └── PythonApiClient.java +│ ├── entity/ForwardingRule.java +│ ├── repository/ForwardingRuleRepository.java +│ ├── dto/RuleRequest.java +│ ├── dto/ApiResponse.java +│ └── config/RestTemplateConfig.java +│ +├── nginx-manager-frontend/ # Vue.js 3 前端 +│ ├── .env.development # 开发环境变量 +│ ├── .env.production # 生产环境变量 +│ ├── vite.config.js # Vite配置(端口3002) +│ └── src/ +│ ├── App.vue # 根组件 +│ ├── layout/MainLayout.vue # 左侧菜单布局 +│ ├── views/NginxForwarding.vue # 转发管理页面 +│ ├── api/nginxApi.js # API请求封装 +│ └── router/index.js # 路由配置 +│ +├── sql/init.sql # MySQL建库建表脚本 +├── IMPLEMENTATION_PLAN.md # 本文档 - 实现方案 +└── DEPLOYMENT.md # 部署文档 - 含全部命令 +``` + +--- + +## 八、启动命令 + +```bash +# === 10.1.1.160 服务器(已配置开机自启)=== +systemctl start nginx-api # Python API (5000) +systemctl start nginx # Nginx (80/443) + +# === Spring Boot 后端 === +cd nginx-manager-backend +mvn spring-boot:run # 开发模式(端口4002) + +# === Vue.js 前端 === +cd nginx-manager-frontend +npm run dev # 开发模式(端口3002,读.env.development) +npm run build # 生产构建(读.env.production) +``` diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 0000000..b286eef --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,9 @@ +Stack trace: +Frame Function Args +000FFFFAE38 0018006401E (0018028FB18, 0018026EFBE, 00000000000, 000FFFF9D40) +000FFFFAE38 0018004973A (00000000000, 00000000000, 00000000000, 0018028FBD0) +000FFFFAE38 00180049772 (0018028FC18, 000FFFFACF8, 00000000000, 00000000000) +000FFFFAE38 001800CAAC2 (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFAE38 001800CAC60 (000FFFFAE80, 00000000000, 00000000000, 00000000000) +000FFFFB130 001800CC475 (000FFFFAE80, 00000000000, 00000000000, 00000000000) +End of stack trace diff --git a/dns-api/app.py b/dns-api/app.py new file mode 100644 index 0000000..5b2b1b5 --- /dev/null +++ b/dns-api/app.py @@ -0,0 +1,126 @@ +""" +DNS 解析管理 API — 独立版本 +部署在 101.132.183.138:32210 上,仅提供阿里云 DNS 解析管理 +从 10.1.1.160 迁移而来,去掉了 Nginx/SSL 相关依赖 +""" +import os +import logging +from flask import Flask, request +from dns_service import DnsService + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +app.config['JSON_AS_ASCII'] = False # 中文不转 Unicode + +# 初始化 DNS 服务(阿里云 AccessKey 从环境变量读取) +dns_service = DnsService() + +# 默认域名 zone,通过环境变量配置,默认 zgitm.com +DEFAULT_ZONE = os.environ.get('DNS_DEFAULT_ZONE', 'zgitm.com') + + +# ═══════════════════════════════════════════════════════════ +# 健康检查 +# ═══════════════════════════════════════════════════════════ + +@app.route('/api/nginx/health', methods=['GET']) +def health_check(): + """健康检查""" + ali_key = os.environ.get('Ali_Key', '') + return { + 'code': 0, + 'message': 'DNS Manager API is running', + 'data': { + 'service': 'dns-api', + 'version': '2.0', + 'default_zone': DEFAULT_ZONE, + 'ali_key_configured': bool(ali_key), + } + } + + +# ═══════════════════════════════════════════════════════════ +# DNS 解析管理接口 +# ═══════════════════════════════════════════════════════════ + +@app.route('/api/nginx/dns/records', methods=['GET']) +def list_dns_records(): + """获取 DNS 解析记录列表""" + try: + zone = request.args.get('zone', DEFAULT_ZONE) + rr = request.args.get('rr', None) + rtype = request.args.get('type', None) + records = dns_service.list_records(zone, rr_keyword=rr, record_type=rtype) + return {'code': 0, 'message': 'success', 'data': records} + except Exception as e: + logger.error(f"获取DNS记录失败: {e}") + return {'code': -1, 'message': str(e), 'data': []} + + +@app.route('/api/nginx/dns/records', methods=['POST']) +def add_dns_record(): + """添加 DNS 解析记录""" + try: + body = request.get_json(force=True) + zone = body.get('zone', DEFAULT_ZONE) + rr = body.get('rr', '').strip() + record_type = body.get('type', 'A').upper() + value = body.get('value', '').strip() + ttl = body.get('ttl', 600) + + if not rr: + return {'code': -1, 'message': '主机记录(rr)不能为空'}, 400 + if not value: + return {'code': -1, 'message': '记录值(value)不能为空'}, 400 + + result = dns_service.add_record(zone, rr, record_type, value, ttl) + return {'code': 0, 'message': f'{result["full_domain"]} 记录已添加', 'data': result} + except Exception as e: + logger.error(f"添加DNS记录失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/dns/records/', methods=['DELETE']) +def delete_dns_record(record_id): + """删除 DNS 解析记录""" + try: + dns_service.delete_record(record_id) + return {'code': 0, 'message': 'DNS记录已删除'} + except Exception as e: + logger.error(f"删除DNS记录失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/dns/records/', methods=['PUT']) +def update_dns_record(record_id): + """修改 DNS 解析记录""" + try: + body = request.get_json(force=True) + rr = body.get('rr', '@') + record_type = body.get('type', 'A').upper() + value = body.get('value', '').strip() + ttl = body.get('ttl', 600) + + if not value: + return {'code': -1, 'message': '记录值(value)不能为空'}, 400 + + dns_service.update_record(record_id, rr, record_type, value, ttl) + return {'code': 0, 'message': 'DNS记录已更新'} + except Exception as e: + logger.error(f"修改DNS记录失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +# ═══════════════════════════════════════════════════════════ +# 启动入口 +# ═══════════════════════════════════════════════════════════ + +if __name__ == '__main__': + logger.info(f"DNS Manager API 启动在端口 32210, 默认 zone: {DEFAULT_ZONE}") + app.run(host='0.0.0.0', port=32210, debug=False) diff --git a/dns-api/dns_service.py b/dns-api/dns_service.py new file mode 100644 index 0000000..7d86f5d --- /dev/null +++ b/dns-api/dns_service.py @@ -0,0 +1,228 @@ +""" +阿里云 DNS 解析管理服务 +通过阿里云 OpenAPI 管理 zgitm.com 的 DNS 解析记录 +使用 HMAC-SHA1 签名,兼容 Python 3.6+ +""" +import os +import hmac +import hashlib +import base64 +import logging +import urllib.request +import urllib.parse +import urllib.error +import json +import time +import uuid + +logger = logging.getLogger(__name__) + +# 阿里云 API 配置 +ALIDNS_ENDPOINT = 'https://alidns.aliyuncs.com' +API_VERSION = '2015-01-09' +SIGNATURE_METHOD = 'HMAC-SHA1' +SIGNATURE_VERSION = '1.0' + + +class DnsService: + """阿里云 DNS 解析管理""" + + def __init__(self): + self.access_key_id = os.environ.get('Ali_Key', '') + self.access_key_secret = os.environ.get('Ali_Secret', '') + + def _sign(self, method, params): + """ + HMAC-SHA1 签名(阿里云 API 签名 v1.0) + """ + # 1. 按参数名排序 + sorted_keys = sorted(params.keys()) + sorted_params = [(k, params[k]) for k in sorted_keys] + + # 2. 构造规范化查询字符串 + canonicalized = urllib.parse.urlencode(sorted_params) + + # 3. 构造签名源字符串 + string_to_sign = method + '&' + urllib.parse.quote('/', safe='') + '&' + urllib.parse.quote(canonicalized, safe='') + + # 4. HMAC-SHA1 签名 + key = (self.access_key_secret + '&').encode('utf-8') + h = hmac.new(key, string_to_sign.encode('utf-8'), hashlib.sha1) + signature = base64.b64encode(h.digest()).decode('utf-8') + + return signature + + def _call(self, action, extra_params=None): + """ + 调用阿里云 OpenAPI + + Args: + action: API 名称 (如 DescribeDomainRecords) + extra_params: 额外参数 + + Returns: + dict: API 响应 + """ + if not self.access_key_id or not self.access_key_secret: + raise RuntimeError('阿里云 AccessKey 未配置') + + # 公共参数 + params = { + 'AccessKeyId': self.access_key_id, + 'Action': action, + 'Format': 'JSON', + 'Version': API_VERSION, + 'SignatureMethod': SIGNATURE_METHOD, + 'SignatureVersion': SIGNATURE_VERSION, + 'SignatureNonce': str(uuid.uuid4()), + 'Timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + } + if extra_params: + params.update(extra_params) + + # 签名 + params['Signature'] = self._sign('GET', params) + + # 构造 URL + url = ALIDNS_ENDPOINT + '/?' + urllib.parse.urlencode(params) + + # 发送请求 + try: + req = urllib.request.Request(url, method='GET') + with urllib.request.urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode('utf-8')) + return result + except urllib.error.HTTPError as e: + body = e.read().decode('utf-8') if e.fp else '' + logger.error(f'DNS API HTTP {e.code}: {body}') + raise RuntimeError(f'DNS API 请求失败: {e.code} {body}') + except Exception as e: + logger.error(f'DNS API 异常: {e}') + raise RuntimeError(f'DNS API 异常: {e}') + + def list_records(self, domain_zone, rr_keyword=None, record_type=None): + """ + 获取域名解析记录列表 + + Args: + domain_zone: 主域名 (如 zgitm.com) + rr_keyword: 主机记录筛选 (如 www) + record_type: 记录类型筛选 (如 A, CNAME) + + Returns: + list[dict]: 解析记录列表 + """ + params = {'DomainName': domain_zone} + if rr_keyword: + params['RRKeyWord'] = rr_keyword + if record_type: + params['Type'] = record_type + + resp = self._call('DescribeDomainRecords', params) + records = resp.get('DomainRecords', {}).get('Record', []) + # 统一转列表 + if isinstance(records, dict): + records = [records] + + result = [] + for r in records: + result.append({ + 'record_id': r.get('RecordId', ''), + 'rr': r.get('RR', ''), + 'type': r.get('Type', 'A'), + 'value': r.get('Value', ''), + 'ttl': r.get('TTL', 600), + 'line': r.get('Line', 'default'), + 'status': r.get('Status', 'ENABLE'), + 'full_domain': (r.get('RR', '') + '.' + domain_zone).strip('.'), + }) + return result + + def add_record(self, domain_zone, rr, record_type, value, ttl=600): + """ + 添加解析记录 + + Args: + domain_zone: 主域名 + rr: 主机记录 (如 webssd, @, www) + record_type: 记录类型 (A, CNAME, TXT 等) + value: 记录值 (IP地址或域名) + ttl: TTL 秒数 + + Returns: + dict: {'record_id': ..., 'full_domain': ...} + """ + params = { + 'DomainName': domain_zone, + 'RR': rr, + 'Type': record_type.upper(), + 'Value': value, + 'TTL': str(ttl), + } + resp = self._call('AddDomainRecord', params) + record_id = resp.get('RecordId', '') + full_domain = (rr + '.' + domain_zone).strip('.').replace('@.', '') + logger.info(f'DNS 记录已添加: {full_domain} {record_type} {value} (ID: {record_id})') + return {'record_id': record_id, 'full_domain': full_domain} + + def delete_record(self, record_id): + """ + 删除解析记录 + + Args: + record_id: 记录ID + + Returns: + bool: 是否成功 + """ + resp = self._call('DeleteDomainRecord', {'RecordId': str(record_id)}) + logger.info(f'DNS 记录已删除: {record_id}') + return True + + def update_record(self, record_id, rr, record_type, value, ttl=600): + """ + 修改解析记录 + + Args: + record_id: 记录ID + rr: 主机记录 + record_type: 记录类型 + value: 记录值 + ttl: TTL + + Returns: + bool: 是否成功 + """ + params = { + 'RecordId': str(record_id), + 'RR': rr, + 'Type': record_type.upper(), + 'Value': value, + 'TTL': str(ttl), + } + try: + self._call('UpdateDomainRecord', params) + logger.info(f'DNS 记录已修改: {record_id} → {rr} {record_type} {value}') + except RuntimeError as e: + msg = str(e) + # 未做任何改动直接保存,阿里云返回 DomainRecordDuplicate,视为成功 + if 'DomainRecordDuplicate' in msg: + logger.info(f'DNS 记录未变更,跳过: {record_id} {rr} {record_type} {value}') + else: + raise + return True + + def set_record_status(self, record_id, status): + """ + 启用/停用解析记录 + + Args: + record_id: 记录ID + status: Enable / Disable + """ + self._call('SetDomainRecordStatus', { + 'RecordId': str(record_id), + 'Status': status.upper(), + }) + logger.info(f'DNS 记录状态已更新: {record_id} → {status}') + return True diff --git a/dns-api/gunicorn_config.py b/dns-api/gunicorn_config.py new file mode 100644 index 0000000..9f4afa9 --- /dev/null +++ b/dns-api/gunicorn_config.py @@ -0,0 +1,29 @@ +""" +Gunicorn 生产环境配置文件 — DNS API +用法: gunicorn -c gunicorn_config.py app:app +""" +import os + +# 监听地址和端口 +bind = "0.0.0.0:32210" + +# Worker 进程数 +workers = int(os.environ.get('GUNICORN_WORKERS', 2)) + +# Worker 类型 +worker_class = "sync" + +# 超时 +timeout = 60 +graceful_timeout = 10 + +# 日志 +accesslog = "-" # 标准输出 +errorlog = "-" # 标准错误 +loglevel = "info" + +# 进程名称 +proc_name = "dns-manager-api" + +# 后台运行 +daemon = False diff --git a/dns-api/requirements.txt b/dns-api/requirements.txt new file mode 100644 index 0000000..398de43 --- /dev/null +++ b/dns-api/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.0 +gunicorn==23.0.0 diff --git a/docs/nginxLinux服务器迁移手册.md b/docs/nginxLinux服务器迁移手册.md new file mode 100644 index 0000000..b577a43 --- /dev/null +++ b/docs/nginxLinux服务器迁移手册.md @@ -0,0 +1,536 @@ +# NginxLinux 服务器迁移手册 + +> **服务器**: 10.1.1.160 (linuxNginx) +> **系统**: Rocky Linux 8.10 +> **更新日期**: 2026-06-12 +> **用途**: Nginx 域名转发管理平台 — Python API + Nginx 反向代理 + +--- + +## 一、迁移概览 + +需要迁移以下内容到新服务器: + +| 类别 | 内容 | 位置 | +|------|------|------| +| 目录 | Python API 项目 | `/opt/nginx-api/` | +| 目录 | SSL 证书 + 内部 CA | `/etc/nginx/ssl/` | +| 目录 | Nginx 转发配置 | `/etc/nginx/conf.d/` | +| 目录 | acme.sh | `/root/.acme.sh/` | +| 文件 | Nginx 主配置 | `/etc/nginx/nginx.conf` | +| 文件 | systemd 服务 | `/etc/systemd/system/nginx-api.service` | +| 文件 | 环境变量 | `/root/.bashrc` (Ali_Key / Ali_Secret) | +| 文件 | Gunicorn 配置 | `/opt/nginx-api/gunicorn_config.py` | +| 定时任务 | acme.sh 自动续期 cron | `crontab -l` | + +--- + +## 二、新服务器环境要求 + +### 2.1 操作系统 + +- Rocky Linux 8.x / CentOS 8.x / RHEL 8.x(或其他 systemd Linux 发行版) +- 最小磁盘: 20G(当前使用 3.6G) +- 内存: 2G+(当前 1.7G,用 ~350M) + +### 2.2 安装软件清单 + +```bash +# 1. Nginx (dnf 安装) +dnf install -y nginx + +# 2. Python 3 (系统自带 ≥ 3.6) +# Rocky 8 自带 Python 3.6.8,无需额外安装 + +# 3. pip 包 +pip3 install flask==2.0.3 gunicorn==21.2.0 +# 注: Python 3.6 不支持 Flask 3.x,实际安装 2.0.x + +# 4. acme.sh (从 GitHub clone,get.acme.sh 可能被墙) +git clone https://github.com/acmesh-official/acme.sh.git /root/.acme.sh +cd /root/.acme.sh && ./acme.sh --install --home /root/.acme.sh + +# 5. 设置默认 CA +/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt +``` + +### 2.3 需要开放的网络 + +| 方向 | 端口/地址 | 用途 | +|------|-----------|------| +| 入站 | 22/tcp | SSH 管理 | +| 入站 | 80/tcp | HTTP 转发 | +| 入站 | 443/tcp | HTTPS 转发 | +| 入站 | 5000/tcp | Python API(仅 Java 后端访问) | +| 出站 | alidns.aliyuncs.com:443 | 阿里云 DNS API(acme.sh + DNS 管理) | +| 出站 | letsencrypt.org:443 | Let's Encrypt 证书签发/续期 | + +--- + +## 三、迁移步骤 + +### Step 0: 准备工作 + +在新服务器上执行: + +```bash +# 创建目录结构 +mkdir -p /etc/nginx/ssl/ca +mkdir -p /etc/nginx/conf.d +mkdir -p /opt/nginx-api/services + +# 创建 nginx-api 用户(可选,当前用 root) +useradd -r -s /sbin/nologin nginx-api || true +``` + +### Step 1: 安装软件 + +```bash +# Nginx +dnf install -y nginx +systemctl enable nginx +systemctl start nginx + +# Python pip 包 +pip3 install flask==2.0.3 gunicorn==21.2.0 + +# acme.sh +git clone https://github.com/acmesh-official/acme.sh.git /root/.acme.sh +/root/.acme.sh/acme.sh --install --home /root/.acme.sh +/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt +``` + +### Step 2: 配置防火墙 + +```bash +firewall-cmd --permanent --add-service=http +firewall-cmd --permanent --add-service=https +firewall-cmd --permanent --add-port=5000/tcp +firewall-cmd --reload + +# SELinux +setsebool -P httpd_can_network_connect 1 +``` + +### Step 3: 迁移 Python API 代码 + +```bash +# 从旧服务器拷贝整个目录 +rsync -avz root@10.1.1.160:/opt/nginx-api/ /opt/nginx-api/ + +# 或手动拷贝文件: +# /opt/nginx-api/ +# app.py — Flask 主入口 +# gunicorn_config.py — Gunicorn 配置 +# deploy.sh — 部署脚本 +# requirements.txt — Python 依赖 +# services/ +# __init__.py +# nginx_service.py — Nginx 配置读写 +# ssl_service.py — 证书签发管理 +# dns_service.py — DNS 解析管理 +# cert_notifier.py — 邮件通知脚本 +``` + +### Step 4: 迁移 SSL 证书目录 + +```bash +# ⚠️ 关键:内部 CA 私钥必须保留 +rsync -avz root@10.1.1.160:/etc/nginx/ssl/ /etc/nginx/ssl/ + +# 确保证书权限正确 +chmod 700 /etc/nginx/ssl/ca +chmod 600 /etc/nginx/ssl/ca/ca.key +chmod 644 /etc/nginx/ssl/ca/ca.crt +chmod 600 /etc/nginx/ssl/*.key +chmod 644 /etc/nginx/ssl/*.pem +``` + +### Step 5: 迁移 Nginx 配置 + +```bash +# 主配置 +rsync -avz root@10.1.1.160:/etc/nginx/nginx.conf /etc/nginx/nginx.conf + +# 所有域名转发配置 +rsync -avz root@10.1.1.160:/etc/nginx/conf.d/ /etc/nginx/conf.d/ + +# 验证配置 +nginx -t + +# 重载 +systemctl reload nginx +``` + +### Step 6: 迁移 acme.sh + +```bash +rsync -avz root@10.1.1.160:/root/.acme.sh/ /root/.acme.sh/ +``` + +### Step 7: 迁移环境变量 + +将以下内容追加到 `/root/.bashrc`: + +```bash +export Ali_Key="LTAI5t98jUxLUadZM1LkaSa8" +export Ali_Secret="K2HyLLXizAgvOMOa0Mzc7ebs4gbAZs" +``` + +然后执行: +```bash +source /root/.bashrc +``` + +### Step 8: 安装 systemd 服务 + +创建 `/etc/systemd/system/nginx-api.service`: + +```ini +[Unit] +Description=Nginx Manager Python API +After=network.target nginx.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/nginx-api +Environment=Ali_Key=LTAI5t98jUxLUadZM1LkaSa8 +Environment=Ali_Secret=K2HyLLXizAgvOMOa0Mzc7ebs4gbAZs +ExecStart=/usr/bin/python3 -m gunicorn -c gunicorn_config.py app:app +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +systemctl daemon-reload +systemctl enable nginx-api +systemctl start nginx-api +``` + +### Step 9: 迁移 crontab + +```bash +# 查看旧服务器 crontab +crontab -l + +# 添加 acme.sh 自动续期(每天 19:01) +echo '1 19 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null' | crontab - +``` + +### Step 10: 验证 + +```bash +# 1. 服务状态 +systemctl status nginx nginx-api + +# 2. 端口监听 +ss -tlnp | grep -E ":(80|443|5000)" + +# 3. Nginx 配置语法 +nginx -t + +# 4. Python API 健康检查 +curl http://localhost:5000/api/nginx/health + +# 5. 证书列表 +curl http://localhost:5000/api/nginx/certs + +# 6. acme.sh 版本 +/root/.acme.sh/acme.sh --version +``` + +--- + +## 四、完整配置文件参考 + +### 4.1 Nginx 主配置 `/etc/nginx/nginx.conf` + +```nginx +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 4096; + + # 全局 gzip 压缩 + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 1000; + gzip_types text/plain text/css text/xml application/json + application/javascript text/javascript application/xml+rss + application/x-font-ttf image/svg+xml; + + # 包含所有域名转发配置 + include /etc/nginx/conf.d/*.conf; +} +``` + +### 4.2 Gunicorn 配置 `/opt/nginx-api/gunicorn_config.py` + +```python +import os + +bind = "0.0.0.0:5000" +pidfile = "gunicorn.pid" +workers = int(os.environ.get('GUNICORN_WORKERS', 2)) +worker_class = "sync" +timeout = 300 +graceful_timeout = 10 +accesslog = "-" +errorlog = "-" +loglevel = "info" +proc_name = "nginx-manager-api" +daemon = False +``` + +### 4.3 域名转发配置模板 + +**HTTP** (`/etc/nginx/conf.d/.conf`): +```nginx +server { + listen 80; + server_name ; + + # 静态资源缓存 + location ~* \.(js|css|svg|woff2|png|jpg|ico)$ { + proxy_pass ; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass ; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} +``` + +**HTTPS** (`/etc/nginx/conf.d/.conf`): +```nginx +# HTTP → HTTPS 重定向 +server { + listen 80; + server_name ; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name ; + + ssl_certificate /etc/nginx/ssl/.pem; + ssl_certificate_key /etc/nginx/ssl/.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # 静态资源缓存 + location ~* \.(js|css|svg|woff2|png|jpg|ico)$ { + proxy_pass ; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass ; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} +``` + +--- + +## 五、证书体系 + +### 5.1 双层证书架构 + +| 类型 | 适用域名 | 签发方式 | 有效期 | 续期方式 | +|------|----------|----------|--------|----------| +| 内部 CA | 非 zgitm 域名 | OpenSSL 自签 | 365天 | 重新签发 | +| Let's Encrypt | `*.zgitm.com` | acme.sh DNS-01 | 90天 | 自动续期(每天cron) | + +### 5.2 内部 CA + +``` +/etc/nginx/ssl/ca/ + ca.key (600) — 根CA私钥 (RSA 2048) + ca.crt (644) — 根CA证书 (有效期10年, 至约2036年) + ca.srl — 序列号文件 +``` + +首次启动时由 `ssl_service.py` 的 `init_ca()` 方法自动生成。**迁移时必须保留此目录**,否则所有内部 CA 签发的域名的 SSL 证书将不被信任。 + +### 5.3 Let's Encrypt (acme.sh) + +- 安装目录: `/root/.acme.sh/` +- 证书存放: `/etc/nginx/ssl/.pem` + `.key` +- 自动续期 cron: `1 19 * * *` (每天) +- DNS 验证: 阿里云 DNS API (`dns_ali`) +- 阿里云 AccessKey 环境变量: `Ali_Key` / `Ali_Secret` +- 当前已签发: `zgitm.com`, `www.zgitm.com` (到期 2026-09-09) + +### 5.4 系统保护域名 + +`zgitm.com` 和 `www.zgitm.com` 为系统内置域名,不允许在转发规则页面创建/修改/删除。三层拦截: +1. 前端: 操作按钮置灰 +2. Java: `ForwardingRuleService.SYSTEM_DOMAINS` +3. Python: `ssl_service.is_system_domain()` + +--- + +## 六、Python API 端点参考 + +基地址: `http://10.1.1.160:5000` + +### 转发规则 + +``` +GET /api/nginx/health → 健康检查 +GET /api/nginx/rules → 规则列表 +GET /api/nginx/rules/ → 规则详情 +POST /api/nginx/rules → 新增规则 {domain, protocol, target} +PUT /api/nginx/rules/ → 修改规则 {domain, protocol, target} +DELETE /api/nginx/rules/ → 删除规则 +POST /api/nginx/reload → 重载 Nginx +``` + +### 证书管理 + +``` +GET /api/nginx/certs → 证书列表(扫描所有 .pem,排除 ca.*) +GET /api/nginx/certs/ → 证书详情 +POST /api/nginx/certs//issue → 签发 LE 证书 +POST /api/nginx/certs//renew → 续期 LE 证书 +``` + +### DNS 管理 + +``` +GET /api/nginx/dns/records?zone= → DNS 记录列表 +POST /api/nginx/dns/records → 添加 DNS 记录 +PUT /api/nginx/dns/records/ → 修改 DNS 记录 +DELETE /api/nginx/dns/records/ → 删除 DNS 记录 +``` + +--- + +## 七、外部依赖关系 + +迁移后需要确保以下连通性: + +| 从 → 到 | 地址 | 端口 | 用途 | +|---------|------|------|------| +| 新服务器 | MySQL | 10.1.1.122 | 3306 | 数据库(Java 用,Python 不直连) | +| 新服务器 → | Java 后端 | 10.10.1.170 | 4002 | Java 调用 Python API 的反向 | +| Java 后端 → | 新服务器 | 新IP | 5000 | **需要更新 Java 配置中的 python-api.base-url** | +| 新服务器 → | 阿里云 DNS | alidns.aliyuncs.com | 443 | DNS 管理 + LE 验证 | +| 新服务器 → | Let's Encrypt | acme-v02.api.letsencrypt.org | 443 | 证书签发/续期 | + +### ⚠️ 迁移后必须更新的配置 + +1. **Java `application-qa.yml` / `application-prod.yml`**: 修改 `python-api.base-url` 为新服务器 IP +2. **前端 `.env.production`**: 如果 Nginx 入口域名换了需更新 `VITE_API_BASE_URL` +3. **DNS 解析**: 如果 `nginx.server.zgitm.com` 指向旧服务器 IP,需更新 DNS 记录 + +--- + +## 八、服务清单 + +| 服务 | systemd unit | 开机自启 | 端口 | +|------|-------------|----------|------| +| Nginx | nginx.service | ✅ | 80, 443 | +| Python API | nginx-api.service | ✅ | 5000 | +| SSH | sshd.service | ✅ | 22 | +| Firewalld | firewalld.service | ✅ | — | + +### 常用命令 + +```bash +# 查看所有服务状态 +systemctl status nginx nginx-api + +# 重载 Nginx(不中断连接) +systemctl reload nginx + +# 重启 Python API +systemctl restart nginx-api + +# 查看日志 +journalctl -u nginx-api -f +journalctl -u nginx -f + +# 测试 Nginx 配置 +nginx -t + +# 手动签发证书(LE 测试) +/root/.acme.sh/acme.sh --issue --dns dns_ali -d test.zgitm.com --staging + +# 手动续期 +/root/.acme.sh/acme.sh --renew -d zgitm.com --force +``` + +--- + +## 九、当前服务器资源使用 + +| 指标 | 值 | +|------|-----| +| 磁盘 | 17G 总量, 3.6G 已用 (22%) | +| 内存 | 1.7G 总量, ~350M 已用 | +| Python API 大小 | 152K | +| acme.sh 大小 | 3.5M | +| Nginx 配置+证书 | 376K | +| 证书文件数 | 20+ .pem | +| 转发规则数 | 17 .conf | + +--- + +## 十、已知注意事项 + +1. **Python 3.6 兼容性**: Rocky 8 自带 Python 3.6,Flask 最高只支持到 2.0.x。`requirements.txt` 写的 flask==3.1.0 实际上被降级安装为 2.0.3。**如果新服务器用 Python 3.9+,可以安装 Flask 3.x**。 + +2. **acme.sh 安装方式**: `curl https://get.acme.sh | sh` 可能被墙,建议用 `git clone` 方式安装。 + +3. **内部 CA 私钥**: `/etc/nginx/ssl/ca/ca.key` 是安全敏感文件,迁移时不要通过不安全渠道传输。RSYNC/SCP 直接传输是安全的。 + +4. **阿里云 AccessKey**: 存储在 `/root/.bashrc` 环境变量中,systemd service 文件中也硬编码了一份(`Environment=`),两处都需要更新。 + +5. **Java 后端 MyBatis-Plus**: 已从 JPA 迁移到 MyBatis-Plus(2026-06-12),新服务器上 Python API 代码已包含 `list_certs()` 修复(扫描全部 .pem)。 + +6. **Nginx gzip**: 主配置中包含全局 gzip 压缩(`/etc/nginx/nginx.conf` 中 `http {}` 块)。迁移时注意 `include /etc/nginx/conf.d/*.conf;` 不要丢失。 diff --git a/memory/.session-last b/memory/.session-last new file mode 100644 index 0000000..b4ca69a --- /dev/null +++ b/memory/.session-last @@ -0,0 +1,6 @@ +{ + "projectPath": "E:\\AI\\claude\\new\\nginxServer", + "endedAt": "2026-06-21T04:00:00+08:00", + "sessionFile": "memory/session-20260621-2.md", + "summary": "DNS解析API从10.1.1.160迁移至101.132.183.138:32210。新建dns-api精简版(4文件),部署到138(systemd开机自启),Java配置改1行指新地址,编译部署到Docker,全链路验证通过。数据库10.1.1.122→10.20.1.122未完成(网络不通已回滚)。" +} diff --git a/memory/MEMORY.md b/memory/MEMORY.md new file mode 100644 index 0000000..a028977 --- /dev/null +++ b/memory/MEMORY.md @@ -0,0 +1,11 @@ +- [会话 2026-06-21 #2](session-20260621-2.md) — DNS解析API从10.1.1.160迁移至101.132.183.138:32210,Java后端改指新地址 +- [会话 2026-06-21 #1](session-20260621-1.md) — 宝塔面板API接入,Nginx反代管理迁移至宝塔(只读),160 nginx废弃,前端只读改造 +- [会话 2026-06-19 #1](session-20260619-1.md) — 审计字段全栈实现:创建人/更新人/时间 + 排序 + DNS本地审计表 + 数据库迁移 + 部署 +- [会话 2026-06-18 #1](session-20260618-1.md) — 网络架构讨论(nginx是否迁阿里云)+ 宝塔API调用方法 +- [会话 2026-06-14 #2](session-20260614-2.md) — 编辑弹窗新增"内容压缩"Tab(gzip配置)+ "日志"Tab(响应/错误日志) +- [会话 2026-06-14 #1](session-20260614-1.md) — HTTPS OPTIONS跨域修复 + 转发规则编辑弹窗Tab改造(转发信息/配置文件/SSL查看) +- [会话 2026-06-13 #1](session-20260613-1.md) — Mokee Gateway统一登录全栈接入(前端动态菜单/Widget/鉴权 + 后端CORS/UserContext + Python中间件 + SQL) +- [会话 2026-06-12 #1](session-20260612-1.md) — Nginx端口修复、证书列表同步、前端移动端适配、网关邮件测试 +- [会话 2026-06-12 #2](session-20260612-2.md) — 修复HTTPS证书列表不显示 + Python API部署 + JPA→MyBatis-Plus迁移 + 服务器迁移文档 +- [会话 2026-06-11 #2](session-20260611-2.md) — Let's Encrypt证书管理全栈实现,zgitm.com正式证书签发,邮件通知定时任务 +- [会话 2026-06-11 #1](session-20260611-1.md) — Nginx 全局 gzip 压缩配置,前端 JS/CSS 加载加速 diff --git a/memory/session-20260611-1.md b/memory/session-20260611-1.md new file mode 100644 index 0000000..44b304c --- /dev/null +++ b/memory/session-20260611-1.md @@ -0,0 +1,51 @@ +--- +name: session-20260611-1 +description: 2026-06-11 第1次会话 — Nginx 全局 gzip 压缩配置 +metadata: + type: session + date: 2026-06-11 + sessionNumber: 1 + startTime: 2026-06-11T00:40+08:00 + endTime: 2026-06-11T01:00+08:00 +--- + +# 2026-06-11 第1次会话 + +## 会话摘要 +用户反馈前端 JS 文件加载非常慢,需要在 Nginx 添加 gzip 压缩。通过 paramiko 连接到 10.1.1.160,在 nginx.conf 的 http 块中添加了全局 gzip 配置,测试验证通过后重载。 + +## 关键操作 + +### Nginx gzip 压缩配置 +- **做了什么**: 在 /etc/nginx/nginx.conf 的 http 块中添加 gzip 压缩配置 +- **原因**: 前端 JS 文件通过 Nginx 代理转发时未压缩,加载非常慢 +- **结果**: gzip 生效(Content-Encoding: gzip),所有代理转发的资源(CSS/JS/JSON/SVG 等)自动压缩 +- **具体配置**: + ``` + gzip on; + gzip_vary on; + gzip_proxied any; # 关键:对代理内容也压缩 + gzip_comp_level 6; + gzip_min_length 1000; # 小于1KB不压缩 + gzip_types text/plain text/css text/xml application/json + application/javascript text/javascript application/xml+rss + application/x-font-ttf image/svg+xml; + ``` + +### 服务器操作 +- 备份文件: /etc/nginx/nginx.conf.bak.gzip +- nginx -t 语法检查: ✅ 通过 +- systemctl reload nginx: ✅ 成功,Nginx active +- curl 测试: Content-Encoding: gzip 正常返回 + +### 本地代码更新 +- `nginx-python-api/services/nginx_service.py`: 模块文档中添加 gzip 配置说明 + +## 重要信息 +- 连接方式: paramiko (root / mokee.) +- gzip 是全局生效的(http 块级别),所有 conf.d/*.conf 的域名配置都自动受益 +- gzip_min_length=1000,小于 1KB 的文件不会被压缩 + +## 待处理 +- [ ] 用户提到"容器当前的配置"——如果是指 Docker 容器内的独立 Nginx,需另外配置 +- [ ] 清除浏览器缓存后测试前端 JS 加载速度是否改善 diff --git a/memory/session-20260611-2.md b/memory/session-20260611-2.md new file mode 100644 index 0000000..012f57f --- /dev/null +++ b/memory/session-20260611-2.md @@ -0,0 +1,76 @@ +--- +name: session-20260611-2 +description: 2026-06-11 第2次会话 — Let's Encrypt证书管理全栈实现 +metadata: + type: session + date: 2026-06-11 + sessionNumber: 2 + startTime: 2026-06-11T22:00+08:00 + endTime: 2026-06-11T22:40+08:00 +--- + +# 2026-06-11 第2次会话 + +## 会话摘要 +为 zgitm.com(阿里云购买域名)实现 Let's Encrypt DNS-01 证书管理全栈功能: +- acme.sh + 阿里云 DNS API 自动签发/续期 +- 系统内置域名保护(不可在页面创建/修改/删除) +- 证书管理独立页面(签发/续期/通知设置) +- Java 端 @Scheduled 定时任务,到期前15天每日10点发邮件提醒,连续15天 +- 邮件通过 Mokee Gateway 对外开放 API 发送(systemCode: nginxServer) + +## 关键操作 + +### 数据库变更 +- `certificates` 表新增字段: cert_type, notify_email, notify_enabled, notify_days_before, last_renew_time, renew_log +- 新建 `cert_notify_log` 表: 记录通知发送日志 +- 插入 zgitm.com / www.zgitm.com 为 letsencrypt 类型证书 + +### 服务器 (10.1.1.160) +- 安装 acme.sh v3.1.4(GitHub clone,绕过 get.acme.sh 被墙) +- 设置默认 CA: Let's Encrypt +- 阿里云 AccessKey 配置在 /root/.bashrc: Ali_Key / Ali_Secret +- 正式签发 zgitm.com 和 www.zgitm.com Let's Encrypt 证书 +- 证书安装至 /etc/nginx/ssl/{domain}.pem + .key +- 到期时间: 2026-09-09,自动续期: 2026-08-10 + +### Python API +- `ssl_service.py`: +约120行,LE_DOMAINS 列表,ensure_le_certificate(),renew_le_certificate(),get_le_cert_info() +- `app.py`: 新增 4 个证书管理端点 + add_rule 中系统域名拦截 + +### Java 后端(新建/修改共8个文件) +- `Certificate.java`: 新实体 +- `CertificateRepository.java`: JPA Repository +- `CertificateService.java`: CRUD + 签发 + 续期 + 通知配置 +- `CertificateController.java`: /api/certificates REST API +- `CertNotifyScheduler.java`: @Scheduled(cron="0 0 10 * * ?") 每天10点检查+发邮件 +- `PythonApiClient.java`: +4个证书API方法,签发/续期用 certRestTemplate(210s超时) +- `ForwardingRuleService.java`: 系统域名保护(增删改拦截) +- `RestTemplateConfig.java`: 新增 certRestTemplate Bean +- `application.yml`: +gateway 配置段 + +### 前端(新建/修改共5个文件) +- `CertManagement.vue`: 证书管理页面(列表/签发/续期/通知设置) +- `router/index.js`: +/cert-management 路由 +- `MainLayout.vue`: +SSL证书管理菜单(Key图标) +- `nginxApi.js`: +5个证书API方法 +- `NginxForwarding.vue`: 系统域名保护(操作按钮置灰+Lock图标,创建时拦截) + +## 架构要点 +- 证书签发流程: Vue → Java → Python API → acme.sh → 阿里云DNS API + Let's Encrypt +- 邮件通知: Java @Scheduled → Mokee Gateway API → 邮件发送 +- 系统域名 zgitm.com/www.zgitm.com 三层保护: 前端拦截 + Java校验 + Python API拒绝 +- 手动创建域名保持内部CA签发(原有逻辑不变) + +## 配置信息 +- Mokee Gateway: https://gw.server.mokee.com (gw.base-url 可配置) +- SystemCode: nginxServer +- 阿里云 AccessKey: LTAI5t98jUxLUadZM1LkaSa8 / K2HyLLXizAgvOMOa0Mzc7ebs4gbAZs +- acme.sh 路径: /root/.acme.sh/acme.sh +- 证书目录: /etc/nginx/ssl/ + +## 待处理 +- [ ] 部署 Java 新版本到服务器并重启 +- [ ] 前端构建部署 +- [ ] 测试邮件通知(可临时改 cron 表达式触发) +- [ ] 确认收件人邮箱(当前默认 admin@mokee.com) diff --git a/memory/session-20260612-1.md b/memory/session-20260612-1.md new file mode 100644 index 0000000..d0b9245 --- /dev/null +++ b/memory/session-20260612-1.md @@ -0,0 +1,73 @@ +--- +name: session-20260612-1 +description: 2026-06-12 第1次会话 — Nginx故障修复、证书列表同步、前端移动端适配、网关邮件测试 +metadata: + type: session + date: 2026-06-12 + sessionNumber: 1 + startTime: 2026-06-12T01:00+08:00 +--- + +# 2026-06-12 第1次会话 + +## 会话摘要 +本次会话完成多项修复和优化:nginx 80/443端口不监听修复、SSL证书列表从Python API自动同步到DB、前端移动端响应式适配、网关邮件接口连通性验证。 + +## 关键操作 + +### 1. Nginx 80/443 端口不监听修复 +- **问题**: 所有域名访问不了,nginx 进程在跑但不监听 80/443 +- **根因**: `/etc/nginx/nginx.conf` 的 `http {}` 块缺少 `include /etc/nginx/conf.d/*.conf;`(上次加 gzip 时误删) +- **修复**: 在 gzip_min_length 行后补回 include 指令,`nginx -t` 通过后 reload +- **结果**: 80/443 恢复监听,域名访问正常 +- **备份**: `/etc/nginx/nginx.conf.bak.20260612` + +### 2. 删除 wg.server.zgitm.com 转发规则 +- **原因**: 用户新增时提示"规则已存在",选择删除重建 +- **操作**: 通过 Python API `DELETE /api/nginx/rules/wg.server.zgitm.com` +- **结果**: 规则和 nginx conf 文件同时删除 + +### 3. SSL 证书列表不显示修复 (Java) +- **问题**: 证书管理页面只显示 2 条(zgitm.com/www.zgitm.com),转发规则创建的 HTTPS 证书不显示 +- **根因**: `CertificateService.listCertificates()` 只查 MySQL certificates 表,转发规则创建时未写入该表 +- **修复**: `listCertificates()` 改为从 Python API 全量同步 + - 调用 `pythonApiClient.listCerts()` 获取文件系统所有证书 + - 自动发现 → 根据 issuer 判断 certType → 写入/更新 DB + - 已有记录保留通知配置,只刷新文件路径/到期时间 +- **文件**: `CertificateService.java` + +### 4. 证书列表性能优化 +- **方案**: 5 分钟缓存 (CACHE_TTL_SECONDS=300) + - 首次同步后 5 分钟内直接读数据库 + - 签发/续期成功后自动失效缓存 (`lastSyncTime = null`) +- **线程安全**: `synchronized` + 双重检查 `isSyncNeeded()` + +### 5. 前端修改 +- **通知设置默认邮箱**: `CertManagement.vue` — 空邮箱时默认 `zg@zgitm.com` +- **API 地址**: `.env.development` / `.env.production` → `https://nginx.server.zgitm.com` + +### 6. 前端移动端适配 +- **MainLayout.vue**: 汉堡菜单按钮 + CSS 动画侧边栏 + 遮罩层 + resize 监听 +- **NginxForwarding.vue**: 工具栏垂直排列、表格横向滚动 (overflow-x: auto)、对话框 95% 宽 +- **CertManagement.vue**: 同上 + 通知对话框适配 +- **DnsManagement.vue**: 工具栏折叠、表格滚动 +- **App.vue**: 全局消除点击高亮、触摸区增大、字号缩小 +- 断点: ≤768px 自动切换移动布局 + +### 7. 网关邮件接口连通性验证 +- **Token**: `POST https://gw.server.zgitm.com/zgapi/v1/open/token` — 200 OK +- **发邮件**: `POST .../zgapi/v1/open/email/send` — 重启后 200 OK +- **网关后端**: `http://10.10.1.170:9000` +- **systemCode**: nginxServer,systemName: nginx配置管理平台 + +## 重要信息 +- 网关: `https://gw.server.zgitm.com` → `http://10.10.1.170:9000` +- Java 后端: `10.10.1.170:4002` (`nginx.server.zgitm.com`) +- Python API: `10.1.1.160:5000` +- MySQL: `10.1.1.122:3306` (nginxserver/mokee.) +- 用户自己用 IDEA 部署 Java/前端 + +## 待处理 +- [ ] 网关邮件接口确认 10.10.1.170:9000 服务稳定 +- [ ] 用户自行部署 Java(CertificateService 改动) +- [ ] 用户自行构建部署前端(MainLayout + 3页面 + 邮箱默认值) diff --git a/memory/session-20260612-2.md b/memory/session-20260612-2.md new file mode 100644 index 0000000..657d8c4 --- /dev/null +++ b/memory/session-20260612-2.md @@ -0,0 +1,83 @@ +--- +name: session-20260612-2 +description: 2026-06-12 第2次会话 — 修复HTTPS转发规则创建后证书列表不显示 +metadata: + type: session + date: 2026-06-12 + sessionNumber: 2 + startTime: 2026-06-12T12:00+08:00 +--- + +# 2026-06-12 第2次会话 + +## 会话摘要 +修复核心Bug:创建 HTTPS 转发规则后,证书管理页面看不到对应证书。根因是 `ForwardingRuleService.addRule()` 只写 `forwarding_rules` 表没写 `certificates` 表,且 Python API 证书列表接口只扫描 `*.zgitm.com.pem` 文件。 + +## 关键操作 + +### 1. CertificateService 新增 syncCertificateRecord() + invalidateCache() +- **做了什么**: + - 新增 `syncCertificateRecord(domain, certPath, keyPath)` 方法,upsert 证书记录 + - 新增 `public void invalidateCache()` 暴露缓存失效 +- **原因**: 创建/更新转发规则时需要同步写入 certificates 表 +- **结果**: ForwardingRuleService 可调用此方法写入证书记录并立即失效缓存 +- **文件**: `CertificateService.java` + +### 2. ForwardingRuleService 注入 CertificateService +- **做了什么**: + - 注入 `CertificateService`(新增 `@Autowired`) + - `addRule()`: HTTPS 协议时调用 `certificateService.syncCertificateRecord()` + - `updateRule()`: 同上 +- **原因**: 修复创建/修改 HTTPS 规则时证书不写入 certificates 表的 Bug +- **结果**: 创建 HTTPS 规则后,证书管理页面立即显示对应证书 +- **文件**: `ForwardingRuleService.java` + +### 3. Python API 扩展证书扫描范围 +- **做了什么**: + - `/api/nginx/certs`: 从只扫描 `*.zgitm.com.pem` 改为扫描全部 `*.pem` 文件 + - `/api/nginx/certs/`: 移除 `is_zgitm_domain()` 限制,支持查询任意域名证书 + - 排除 CA 根证书文件(ca.pem, ca.crt) +- **原因**: 内部 CA 签发的非 zgitm 域名证书无法通过同步机制发现 +- **文件**: `app.py` + +## 数据流(修复后) + +``` +创建 HTTPS 规则 → addRule() + ├── Python API 签发证书 ✅ + ├── forwarding_rules 表 ✅ + └── certificates 表 ✅ 新增!(syncCertificateRecord + invalidateCache) + +证书管理页面 → listCertificates() + └── 缓存已失效 → 直接查 DB → 立即显示 ✅ +``` + +## 邮件通知架构确认 + +邮件通知和定时器已在 Java 后端实现: +- `CertNotifyScheduler.java`: @Scheduled(cron="0 0 10 * * ?") 每天10点 +- 通过网关 API (`gw.server.zgitm.com`) 发送 HTML 邮件 +- 只通知 letsencrypt 类型证书(含 notify 配置) + +### 4. JPA → MyBatis-Plus 迁移 +- **做了什么**: 全栈替换 Java 持久层框架,23 个文件改动 +- **原因**: 用户要求使用 MyBatis-Plus 替代 JPA +- **结果**: mvn clean compile BUILD SUCCESS,零错误 +- **文件**: pom.xml, application.yml, 3 Entity, 3 Mapper(新), 1 MetaObjectHandler(新), 3 Service, 1 Controller, 删除 3 Repository + +### 6. Vue 前端移动端深度适配 +- **做了什么**: 3 个页面表格→卡片视图,新增 useMobile composable,MainLayout 复用 + 侧滑关闭,App.vue 全局移动样式 +- **原因**: 6-7列表格在手机上严重挤压,需卡片化显示 +- **结果**: BUILD SUCCESS,移动端 Table→Card,桌面端不变 +- **文件**: + - 新增 `src/composables/useMobile.js` + - 修改 3 个 views (NginxForwarding/CertManagement/DnsManagement) — +卡片模板 + 卡片样式 + - 修改 MainLayout.vue — 复用 useMobile + 侧滑关闭 + - 修改 App.vue — 全局移动端增强(安全区域、字号、触摸优化) + +### 7. gzip 修复 + proxy_buffering 优化 +- **问题1**: nginx.conf 缺少 `gzip_types`,只压缩 text/html,JS/CSS 完全不压缩 +- **修复**: 添加 `gzip_types` 14 种 MIME 类型,nginx -t 通过后 reload,`Content-Encoding: gzip` 生效 +- **问题2**: `web.gw.zgitm.com` 代理访问 40s vs 直连 3-10s +- **根因**: proxy_buffering 默认开启,nginx 等后端全部返回后才发给客户端,延迟叠加 +- **修复**: 全部 17 个 conf.d 配置加 `proxy_buffering off`,Python API 模板同步更新(4处),确保未来新增规则也生效 diff --git a/memory/session-20260613-1.md b/memory/session-20260613-1.md new file mode 100644 index 0000000..1b6f66e --- /dev/null +++ b/memory/session-20260613-1.md @@ -0,0 +1,96 @@ +--- +session_id: 20260613-1 +date: 2026-06-13 +summary: Mokee Gateway统一登录接入 - 全栈改造 + 多个Bug修复 +status: 进行中 - Widget未显示,其他功能正常 +--- + +# Mokee Gateway 统一登录接入改造 + +系统编码: `nginxServer`,网关: `https://gw.server.zgitm.com`,登录: `https://login.user.zgitm.com` + +## 最终改动的文件(汇总) + +### 前端 (9 files) +| 文件 | 操作 | 说明 | +|------|------|------| +| `.env.development` | 改 | VITE_GATEWAY_URL / VITE_LOGIN_URL / VITE_SYSTEM_CODE | +| `.env.production` | 改 | 同上 | +| `index.html` | 改 | 引入 Widget CSS(JS 改在 main.js 动态加载) | +| `src/main.js` | 重写 | bootstrap() 鉴权→拉菜单→挂载→加载 Widget | +| `src/utils/request.js` | 新增 | 网关 axios 实例,带 Token/X-System-Code,兼容双 code 格式,错误消息提取 | +| `src/api/nginxApi.js` | 改 | 换新 request,路径加 /api 前缀 | +| `src/router/index.js` | 重写 | 路由守卫,扁平路由注册,导出 menuTree | +| `src/layout/MainLayout.vue` | 改 | 侧边栏从 menuTree 渲染(支持目录层级),ICON_MAP 字符串→组件映射,Widget 占位 | +| `index.html` | 改 | Widget CSS,已移除静态 JS(改动态加载) | + +### 后端 (5 files) +| 文件 | 操作 | 说明 | +|------|------|------| +| `config/CorsConfig.java` | 新增 | allowedOriginPatterns("https://*.zgitm.com","http://localhost:*") + allowCredentials | +| `context/UserContext.java` | 新增 | ThreadLocal 工具类(修复 JDK 17 兼容:不用 getOrDefault) | +| `filter/UserContextFilter.java` | 新增 | Servlet Filter 提取网关注入的 X-User-Id 等请求头 | +| `entity/ForwardingRule.java` | 改 | status 字段加 @TableLogic(value="1", delval="0") | +| `entity/DnsZoneConfig.java` | 改 | status 字段加 @TableLogic(value="1", delval="0") | +| `config/RestTemplateConfig.java` | **未改** | 保持原样,Spring Boot 直连 Python API | + +### 控制器 (3 files) +| 文件 | 操作 | 说明 | +|------|------|------| +| `ForwardingRuleController.java` | 改 | 删除 @CrossOrigin(origins="*"),CORS 统一由 CorsConfig 管 | +| `CertificateController.java` | 改 | 同上 | +| `DnsController.java` | 改 | 同上 | + +### 配置 (1 file) +| 文件 | 操作 | 说明 | +|------|------|------| +| `application.yml` | 改 | **删除全局 logic-delete-field: status**(这是证书重复键Bug的根因) | + +### Python API (1 file) +| 文件 | 操作 | 说明 | +|------|------|------| +| `nginx-python-api/app.py` | 改 | 添加 before_request 中间件提取用户请求头到 Flask g(**未部署到服务器**) | + +### SQL (1 file) +| 文件 | 操作 | 说明 | +|------|------|------| +| `sql/gateway_init.sql` | 新增 | UUID 变量方式,菜单:Nginx代理管理→三个业务页面,角色:admin/user,管理员:admin_nginxServer | + +## 踩过的坑 + +### 1. `@CrossOrigin(origins="*")` + `CorsConfig.allowCredentials(true)` 冲突 +- 三个 Controller 原来有 `@CrossOrigin(origins="*")`,与全局 CorsConfig 的 `allowCredentials(true)` 合并后产生非法组合 +- **修复**:删除三个 Controller 的 `@CrossOrigin`,CORS 统一由 CorsConfig 管理 + +### 2. 网关菜单返回字段和前端预期不一致 +- 网关实际返回:`{path, name, component, meta:{title,icon}, children}`,`component="Layout"` 表示目录 +- 前端最初判断 `menuType`/`menu_name` 等字段,全部对不上 +- **修复**:`addDynamicRoutes()` 改为判断 `component==='Layout'`,路由扁平注册到 `/`,侧边栏从 `menuTree` 渲染 + +### 3. Widget `process is not defined` +- Widget JS 内部引用了 `process.env`,浏览器环境无此全局变量 +- **修复**:`main.js` 中加载 Widget 前加 `window.process = { env: {} }` + +### 4. 路由嵌套导致菜单不显示 +- 最初 `addDynamicRoutes` 把页面路由嵌套在 `/nginx/nginx-forwarding` 下,redirect `/nginx-forwarding` 找不到 +- **修复**:页面路由全部扁平注册到 `/`,侧边栏层级单独从 menuTree 渲染 + +### 5. 证书列表查询触发 INSERT 重复键 — 全局 logic-delete-field 误伤 +- **根因**:`application.yml` 全局 `logic-delete-field: status`,MyBatis-Plus 给所有实体加 `AND status = 1` +- `Certificate.status` 是 String "active",`WHERE status = 1` 永远不成立 → selectOne 返回 null → 走 INSERT → 重复键 +- `ForwardingRule.status` 和 `DnsZoneConfig.status` 是 Integer 0/1,不受影响 +- **修复**:删除全局 logic-delete-field,在 ForwardingRule 和 DnsZoneConfig 的 status 字段加 `@TableLogic(value="1", delval="0")` + +### 6. 前端错误消息丢失 +- 新 `request.js` 错误拦截器直接透传 axios error,前端拿到 "Request failed with status code 500" +- 旧代码从 `error.response.data.message` 提取后端实际消息 +- **修复**:错误拦截器追加 `new Error(error.response?.data?.message || error.message || '网络错误')` + +### 7. Widget 动态加载时机 +- Widget 脚本在 `index.html` 同步加载时 Vue 未渲染,找不到 `#mokee-user-chip` +- 移到 `main.js` 的 `app.mount()` 之后动态加载 +- **当前状态:Widget 仍未显示,待排查** + +## 待解决 +- [ ] Widget 用户头像组件不显示(右上角) +- [ ] Python API `app.py` 中间件需部署到 10.1.1.160 diff --git a/memory/session-20260614-1.md b/memory/session-20260614-1.md new file mode 100644 index 0000000..fdac1bd --- /dev/null +++ b/memory/session-20260614-1.md @@ -0,0 +1,81 @@ +--- +name: session-20260614-1 +description: 2026-06-14 第1次会话 — HTTPS OPTIONS跨域修复 + 转发规则编辑弹窗Tab改造(转发信息/配置文件/SSL) +metadata: + type: session + date: 2026-06-14 + sessionNumber: 1 + startTime: 2026-06-14T09:00:00+08:00 +--- + +# 2026-06-14 第1次会话 + +## 会话摘要 +修复了 `file.wg.zgitm.com` 的 CORS 跨域问题(OPTIONS 预检无 CORS 头),并将转发规则编辑弹窗改为宝塔风格的三 Tab 布局(转发信息/配置文件/SSL),新增配置文件在线编辑和 SSL 证书密钥查看功能。 + +## 关键操作 + +### 1. HTTPS 模板 OPTIONS 预检 CORS 修复 +- **做了什么**: 在 `nginx_service.py` HTTPS_TEMPLATE 的 server 级别添加 CORS add_header(`always` 参数),确保 `if ($request_method = OPTIONS) { return 204; }` 拦截时也能带上 CORS 响应头 +- **根因**: OPTIONS 请求被 server 级 `if` 拦截返回 204,但 CORS add_header 只在 location 块内,没机会执行 +- **验证**: 修复前 OPTIONS 返回 `204` 无任何 CORS 头;修复后返回 `access-control-allow-*` 三个头 +- **结果**: 已验证通过,模板已修复并部署到 10.1.1.160 + +### 2. 新增配置文件查看/编辑接口 +- **做了什么**: Python API 新增 `GET/PUT /api/nginx/rules//config`,后端 Java 新增透传方法,前端编辑弹窗新增"配置文件"Tab +- **GET**: 读取 `/etc/nginx/conf.d/{domain}.conf` 返回原始内容 +- **PUT**: 写入配置 → `nginx -t` 语法检查 → 失败自动回滚 → 成功则 reload +- **结果**: Python API 已部署到 10.1.1.160,后端和前端待用户部署 + +### 3. 新增 SSL 证书/密钥查看接口 +- **做了什么**: Python API 新增 `GET /api/nginx/rules//ssl`,读取 `.pem` 和 `.key` 文件内容返回,前端 SSL Tab 只读显示 +- **结果**: Python API 已部署,后端和前端待用户部署 + +### 4. 编辑弹窗改为 Tab 布局(900px 宽) +- **做了什么**: NginxForwarding.vue 弹窗改为 `el-tabs` 三标签页: + - **转发信息**: 协议类型/域名/转发目标(原有表单,HTTPS 规则不可切换回 HTTP) + - **配置文件**: nginx.conf 查看、编辑(黄色背景标识编辑模式)、保存并重载 + - **SSL**: 仅 HTTPS 规则显示,证书(PEM)和密钥(KEY)只读查看 +- **原因**: 用户要宝塔面板风格的配置编辑体验 +- **结果**: 前端代码已改完,待用户构建部署 + +## 涉及文件 + +### Python API(已部署到 10.1.1.160:/opt/nginx-api/) +| 文件 | 操作 | 说明 | +|------|------|------| +| `services/nginx_service.py` | 改 | HTTPS_TEMPLATE server 级加 CORS add_header | +| `app.py` | 改 | 新增 config 查看/编辑 + SSL 查看接口 | + +### 后端 Java(待用户部署) +| 文件 | 操作 | 说明 | +|------|------|------| +| `service/PythonApiClient.java` | 改 | 新增 getRuleConfig/updateRuleConfig/getSslFiles | +| `service/ForwardingRuleService.java` | 改 | 新增 getConfig/updateConfig/getSslFiles | +| `controller/ForwardingRuleController.java` | 改 | 新增 GET/PUT /{id}/config + GET /{id}/ssl | + +### 前端 Vue(待用户部署) +| 文件 | 操作 | 说明 | +|------|------|------| +| `api/nginxApi.js` | 改 | 新增 getRuleConfig/updateRuleConfig/getRuleSslFiles | +| `views/NginxForwarding.vue` | 重写弹窗 | Tab 布局(转发信息/配置文件/SSL) | + +## 重要信息 + +### 服务器架构(本次确认) +- **10.1.1.160**: Nginx 反向代理 + Python API (`/opt/nginx-api/`, systemd: `nginx-api`) +- **10.1.1.170**: Mokee Gateway (9000端口) + 前端静态文件 (`/opt/mokee/web/`, 通过 docker-proxy 3002 暴露) + 部署服务 (4003端口) +- **10.1.1.160 上的域名配置**: /etc/nginx/conf.d/ 下各 .conf 文件 +- **10.1.1.160 SSL 证书目录**: /etc/nginx/ssl/{domain}.pem + {domain}.key + +### SSH 连接方式 +- 10.1.1.160: root / mokee. (需密码,通过 Python paramiko) +- 10.1.1.170: root / mokee. (需密码,通过 Python paramiko) + +### Python 远程操作 +- Python 路径: `C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe` +- 使用 paramiko 连接 SSH,pymysql 连接 MySQL + +## 待处理 +- [ ] 用户自行部署 Java 后端和 Vue 前端 +- [ ] 验证前端 Tab 布局弹窗完整功能 diff --git a/memory/session-20260614-2.md b/memory/session-20260614-2.md new file mode 100644 index 0000000..41dd09e --- /dev/null +++ b/memory/session-20260614-2.md @@ -0,0 +1,107 @@ +--- +name: session-20260614-2 +description: 2026-06-14 第2次会话 — 转发规则编辑弹窗新增"内容压缩"和"日志"两个Tab页 +metadata: + type: session + date: 2026-06-14 + sessionNumber: 2 + startTime: 2026-06-14T22:30:00+08:00 + endTime: 2026-06-15T00:40:00+08:00 +--- + +# 2026-06-14 第2次会话 + +## 会话摘要 +在Nginx转发管理编辑弹窗中新增了"内容压缩"(仿宝塔gzip配置)和"日志"(响应日志/错误日志)两个Tab页。全栈实现:数据库 → Python API → Java后端 → Vue前端。Python API已部署到10.1.1.160并测试通过。 + +## 关键操作 + +### 1. 新增"内容压缩"Tab(第4个标签页) +- **做了什么**: 在编辑弹窗新增"内容压缩"Tab,包含:gzip开关、12种MIME类型复选框(与宝塔一致)、压缩级别(1-9)、最小压缩长度(支持k/m单位) +- **涉及文件**: + - `sql/gzip_migration.sql` — 4个新字段 + - `ForwardingRule.java` — gzipEnabled/gzipTypes/gzipCompLevel/gzipMinLength + - `nginx_service.py` — parse_gzip_config()/set_gzip_config() 方法 + - `app.py` — GET/PUT `/api/nginx/rules//gzip` + - `PythonApiClient.java` — getGzipConfig/updateGzipConfig + - `ForwardingRuleService.java` — getGzipConfig/updateGzipConfig(同步DB+Nginx) + - `ForwardingRuleController.java` — GET/PUT `/{id}/gzip` + - `nginxApi.js` — getRuleGzip/updateRuleGzip + - `NginxForwarding.vue` — 内容压缩Tab(el-switch + el-checkbox-group + el-select) +- **实现方式**: Python API直接修改Nginx配置文件中的gzip指令(正则删除旧的→插入新的→nginx -t→reload,失败自动回滚) +- **结果**: 10.1.1.160已验证,gzip配置读写正常,nginx语法检查通过 + +### 2. 新增"日志"Tab(第5个标签页) +- **做了什么**: 新增"日志"Tab,通过el-radio-button切换"响应日志"(access.log)/"错误日志"(error.log),支持最近50/100/200/500行 +- **涉及文件**: + - `nginx_service.py` — get_domain_logs() 方法(tail + grep过滤域名) + - `app.py` — GET `/api/nginx/rules//logs?lines=N` + - `PythonApiClient.java` — getDomainLogs + - `ForwardingRuleService.java` — getDomainLogs + - `ForwardingRuleController.java` — GET `/{id}/logs` + - `nginxApi.js` — getRuleLogs + - `NginxForwarding.vue` — 日志Tab(el-radio-group + el-textarea只读) +- **结果**: 10.1.1.160已验证,日志读取正常 + +### 3. Bug修复 +- **问题**: set_gzip_config首次插入后,再次更新时旧的`# 内容压缩(gzip)`注释不会被删除,导致重复 +- **修复**: 在删除正则中增加对`# 内容压缩`注释行的清理 +- **结果**: 已修复并重新部署,确认干净 + +## 涉及文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `sql/gzip_migration.sql` | 新增 | 安全迁移脚本(存储过程检查列是否存在) | +| `ForwardingRule.java` | 改 | 新增4个gzip字段 | +| `services/nginx_service.py` | 改 | 新增parse_gzip_config/set_gzip_config/get_domain_logs | +| `app.py` | 改 | 新增gzip GET/PUT + logs GET 端点 | +| `PythonApiClient.java` | 改 | 新增getGzipConfig/updateGzipConfig/getDomainLogs | +| `ForwardingRuleService.java` | 改 | 新增gzip业务方法(同步DB+Nginx)+ logs方法 | +| `ForwardingRuleController.java` | 改 | 新增 GET/PUT `/{id}/gzip` + GET `/{id}/logs` | +| `api/nginxApi.js` | 改 | 新增getRuleGzip/updateRuleGzip/getRuleLogs | +| `views/NginxForwarding.vue` | 改 | 新增内容压缩Tab + 日志Tab | + +## 重要信息 + +### 部署状态 +- **Python API (10.1.1.160)**: 已部署,已验证gzip和logs端点 ✓ +- **备份**: `/opt/nginx-api-backup-20260614-224351` +- **数据库迁移**: sql/gzip_migration.sql 待执行(MySQL 10.1.1.122) +- **Java后端**: 编译通过,待用户部署 +- **Vue前端**: 编译通过(npm build 3.85s),待用户部署 + +### 编辑弹窗Tab结构(现在共5个Tab) +1. 转发信息 — 始终可见 +2. 配置文件 — 仅编辑模式 +3. SSL — 仅编辑+HTTPS模式 +4. 内容压缩 — 仅编辑模式(新增) +5. 日志 — 仅编辑模式(新增) + +### gzip默认值 +- 开关: 关闭 +- MIME类型: 12种(与宝塔一致) +- 压缩级别: 6 +- 最小长度: 1000(字节) + +### 4. 全局gzip决策变更 +- **做了什么**: 用户要求去掉nginx.conf全局gzip,让每个域名通过页面独立控制。后又改为保留全局、隐藏Tab,最终又改回去掉全局、保留Tab +- **最终结果**: 全局gzip已从 /etc/nginx/nginx.conf 移除(备份: nginx.conf.bak-20260615-002300),"内容压缩"Tab保留 `v-if="isEditing"` + +### 5. 前端Bug修复 — gzipLoadedOnce未重置 +- **问题**: 开启gzip保存后关闭弹窗再打开,开关显示"关闭" +- **根因**: `resetConfigState()` 重置了表单数据但没重置 `gzipLoadedOnce = false`,导致 `onTabChange` 跳过 `loadGzipConfig()` +- **修复**: 在 `resetConfigState()` 添加 `gzipLoadedOnce = false`,同时简化 `onTabChange` 条件判断 +- **结果**: 每次打开弹窗都重新从服务器加载gzip配置 + +### 6. 未部署导致的"配置文件无gzip"问题 +- **现象**: 页面上开启压缩后,配置文件Tab看不到gzip指令 +- **根因**: Java后端未部署更新版(旧版无 /{id}/gzip 端点),PUT请求到不了Python API +- **验证**: 直接在10.1.1.160上curl测试Python API,gzip读写均正常,nginx -t通过 +- **解决**: 需用户部署Java后端jar + Vue前端dist + +## 待处理 +- [x] MySQL 数据库迁移已完成(4列添加成功:gzip_enabled/gzip_types/gzip_comp_level/gzip_min_length) +- [x] 全局gzip已从nginx.conf移除 +- [ ] 用户部署Java后端和Vue前端 +- [ ] 验证完整前端→后端→Python API链路 diff --git a/memory/session-20260618-1.md b/memory/session-20260618-1.md new file mode 100644 index 0000000..c014f67 --- /dev/null +++ b/memory/session-20260618-1.md @@ -0,0 +1,46 @@ +--- +name: session-20260618-1 +description: 2026-06-18 第1次会话 — 网络架构讨论:nginx是否迁到阿里云 + 宝塔API调用方法 +metadata: + type: session + date: 2026-06-18 + sessionNumber: 1 + startTime: 2026-06-18T10:00:00+08:00 +--- + +# 2026-06-18 第1次会话 + +## 会话摘要 +本次会话主要是技术咨询,讨论了网络架构优化方案和宝塔面板 API 调用方法。无代码改动。 + +## 关键操作 + +### 1. 网络架构讨论 — nginx 是否迁到阿里云 +- **做了什么**: 分析了当前架构(用户→阿里云VPN→160 nginx→170应用)与迁移方案(nginx迁到阿里云→170应用)的延迟差异 +- **拓扑梳理**: + - 阿里云 VPN 服务器作为中转枢纽,所有 VPN 客户端流量经过它转发 + - 160/170 都在办公网内网环境,通过 VPN 连接阿里云 + - 用户必须连 VPN 才能访问内网服务 +- **结论**: 两种方案物理路径相同(用户→阿里云→办公网),延迟差异在个位数毫秒级,体感无差别 +- **决策**: 不迁移。迁移的真正收益是"用户不用连VPN"(体验提升),而非速度提升。既然核心诉求是更快,没必要折腾 + +### 2. 宝塔面板 API 调用方法 +- **做了什么**: 介绍了宝塔面板 HTTP API 的调用方式 +- **签名规则**: `request_token = MD5(request_time + api_key)` +- **必须参数**: request_token、request_time +- **常用接口**: 站点管理、数据库管理、服务重启、系统信息获取 +- **注意事项**: + - 需在面板设置中开启API并配置白名单IP + - 旧版(7.x以前)API路径不同,用 `/ajax?action=xxx` + - 建议面板端口不暴露公网,通过SSH隧道或内网调用 +- **结果**: 用户了解后未要求实际操作 + +## 重要信息 +- 当前架构: 阿里云VPN ←VPN隧道→ 10.10.1.160(nginx) ─内网→ 10.1.1.170(应用) +- 域名绑定在 VPN 网络 IP (10.10.1.160) 上 +- 上次部署状态(来自 6/14 会话): gzip+日志功能Python API已部署,Java后端+Vue前端编译通过待部署 + +## 待处理 +- [ ] 部署 Java 后端 jar(含 gzip/logs 端点) +- [ ] 部署 Vue 前端 dist +- [ ] 验证完整前端→后端→Python API 链路 diff --git a/memory/session-20260619-1.md b/memory/session-20260619-1.md new file mode 100644 index 0000000..7515c73 --- /dev/null +++ b/memory/session-20260619-1.md @@ -0,0 +1,88 @@ +--- +name: session-20260619-1 +description: 2026-06-19 第1次会话 — 审计字段全栈实现:创建人/更新人/时间 + 按创建时间倒序 + DNS本地审计表 +metadata: + type: session + date: 2026-06-19 + sessionNumber: 1 + startTime: 2026-06-19T23:00:00+08:00 +--- + +# 2026-06-19 第1次会话 + +## 会话摘要 +全栈实现了审计字段功能:Nginx转发管理和DNS解析管理两张表增加创建人/时间/更新人/时间字段,前端显示并按创建时间倒序排列。数据库迁移已执行,Java后端和Vue前端已编译部署到10.1.1.170的Docker容器。 + +## 关键操作 + +### 1. 数据库迁移 +- **做了什么**: 给 `forwarding_rules`、`dns_zone_config`、`certificates` 三张表新增 `created_by VARCHAR(100)` 和 `updated_by VARCHAR(100)` 列;新建 `dns_records` 本地审计表 +- **SQL文件**: `sql/audit_migration.sql`(用存储过程+INFORMATION_SCHEMA安全检查列是否已存在) +- **执行方式**: Python pymysql 脚本直连 10.1.1.122 +- **结果**: 6个审计列全部添加成功,dns_records表已创建 + +### 2. Java实体层改动 +- **做了什么**: ForwardingRule.java、DnsZoneConfig.java、Certificate.java 三个实体新增 `createdBy`/`updatedBy` 字段(@TableField + FieldFill自动填充);新建 DnsRecord.java 实体和 DnsRecordMapper.java +- **结果**: 编译通过 + +### 3. MyMetaObjectHandler 自动填充 +- **做了什么**: 新增 `resolveOperator()` 方法从 UserContext 获取当前用户,优先级:userName > userId > "系统";insertFill 同时填充 createdAt/updatedAt/createdBy/updatedBy;updateFill 同时填充 updatedAt/updatedBy +- **结果**: 用户操作自动记录创建人/更新人;定时任务等无用户上下文场景写入"系统" + +### 4. 业务层改动 +- **ForwardingRuleService.java**: listRules() 增加 `.orderByDesc(ForwardingRule::getCreatedAt)` — 最近创建排最前 +- **新建 DnsRecordService.java**: 核心理念"阿里云DNS为权威数据源,本地dns_records仅做审计追踪";listRecords()合并阿里云记录+本地审计字段;addRecord/updateRecord/deleteRecord先操作阿里云再同步本地 +- **DnsController.java**: 注入 DnsRecordService 替代直接调 PythonApiClient + +### 5. 前端改动 +- **NginxForwarding.vue**: 桌面表格新增"创建人"(width:100)和"创建时间"(width:160)列;移动端卡片新增创建人和创建时间行;新增 formatDate() 函数 +- **DnsManagement.vue**: 同上 +- **结果**: 历史数据(null)显示"-",新数据正确显示创建人名称和格式化时间 + +### 6. 部署 +- **数据库**: 已在10.1.1.122:3306/nginxserver执行迁移(QA/PROD同一数据库) +- **Java后端**: JAR编译后部署到10.1.1.170 Docker容器 `nginx-server-nginx-server`,重启成功,端口4002正常 +- **Vue前端**: dist编译后部署到10.1.1.170 Docker容器 `nginx-web-nginx-web`,端口3002正常 +- **Python API**: 无需改动 + +## 重要信息 + +### 部署架构(10.1.1.170 Docker) +| 容器 | 端口 | 镜像 | +|------|------|------| +| nginx-server-nginx-server | 4002 | mokee-nginx-server-nginx-server | +| nginx-web-nginx-web | 3002 | mokee-nginx-web-nginx-web | +| mokeegateway-mokee-gateway-gateway | 9000 | Mokee Gateway | + +### 数据库 +- MySQL 10.1.1.122:3306/nginxserver(QA和PROD共用) +- 新增表 `dns_records` — DNS记录本地审计 +- 3张表新增审计列:forwarding_rules、dns_zone_config、certificates + +### 审计字段自动填充逻辑 +- 优先取 UserContext.getUserName()(网关X-User-Name头) +- 其次取 UserContext.getUserId()(网关X-User-Id头) +- 都没有则填"系统"(定时任务场景) + +## 涉及文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `sql/audit_migration.sql` | 新增 | SQL迁移脚本(安全添加列+建表) | +| `scripts/run_audit_migration.py` | 新增 | Python数据库迁移执行脚本 | +| `entity/DnsRecord.java` | 新增 | DNS记录本地审计实体 | +| `mapper/DnsRecordMapper.java` | 新增 | DNS记录Mapper | +| `service/DnsRecordService.java` | 新增 | DNS记录业务服务(阿里云+本地合并) | +| `entity/ForwardingRule.java` | 改 | 新增 createdBy/updatedBy | +| `entity/DnsZoneConfig.java` | 改 | 新增 createdBy/updatedBy | +| `entity/Certificate.java` | 改 | 新增 createdBy/updatedBy | +| `config/MyMetaObjectHandler.java` | 改 | 新增 createdBy/updatedBy 自动填充 | +| `service/ForwardingRuleService.java` | 改 | listRules() 加 ORDER BY created_at DESC | +| `controller/DnsController.java` | 改 | 改用 DnsRecordService | +| `views/NginxForwarding.vue` | 改 | 新增创建人/创建时间列 + formatDate | +| `views/DnsManagement.vue` | 改 | 新增创建人/创建时间列 + formatDate | + +## 待处理 +- [ ] 用户通过网关登录后新增一条转发规则,验证创建人显示为实际用户名(而非"系统") +- [ ] 用户新增一条DNS记录,验证创建人/时间正确显示 +- [ ] 后续可考虑清理策略:dns_records表中status=0的记录定期归档 diff --git a/memory/session-20260621-1.md b/memory/session-20260621-1.md new file mode 100644 index 0000000..a9367a5 --- /dev/null +++ b/memory/session-20260621-1.md @@ -0,0 +1,99 @@ +--- +name: session-20260621-1 +description: 2026-06-21 第1次会话 — 宝塔面板API接入,Nginx反代管理从160迁移至宝塔面板(只读) +metadata: + type: session + date: 2026-06-21 + sessionNumber: 1 + startTime: 2026-06-20T23:00:00+08:00 +--- + +# 2026-06-21 第1次会话 + +## 会话摘要 +将 Nginx 转发管理从 10.1.1.160(Python API 直接操作配置文件)迁移至宝塔面板 API 只读模式。新增 `BTPanelApiClient` 替代原 Python API 调用,前端改为只读展示。用户决定 160 机器 nginx 卸载,反代操作统一在宝塔面板上进行。 + +## 关键操作 + +### 1. 宝塔面板 API 客户端实现 +- **做了什么**: 新建 `BTPanelApiClient.java`,封装宝塔 API 签名逻辑(`MD5(request_time + MD5(api_key))`),实现 5 个接口 +- **接口清单**: + - `POST /data?action=getData&table=sites` → 站点列表(12个站点) + - `POST /site?action=GetProxyList&sitename=xxx` → 反代列表 + - `POST /acme?action=get_orders` → ACME 证书订单列表 + - `POST /files?action=GetFileBody` → 读取文件内容(nginx 配置文件) + - `getNginxConfig(domain)` → 获取指定域名的 nginx 配置文件 +- **原因**: 10.1.1.160 nginx 卸载,反代功能迁至宝塔面板,平台改为只读看板 +- **结果**: 三个核心 API 在 curl 测试全部通过,Java 编译部署成功 + +### 2. 宝塔 API 调试过程 +- **问题1**: 最初API路径用错(`/site?action=GetSiteList` 返回404),根据官方文档 `api-doc.pdf` 修正为 `/data?action=getData&table=sites` +- **问题2**: IP白名单拦截 → 用户添加 `183.192.136.97`(10.1.1.170 出公网IP)后解决 +- **问题3**: Java POST form body 返回404 → 改为 URL query string 方式传参解决 +- **问题4**: 自签名SSL证书 → RestTemplate 配置跳过证书校验 + +### 3. ForwardingRuleService 改造 +- **做了什么**: `listRules()` 改为从宝塔 API 拉数据,遍历站点列表 → 对每个站点调 GetProxyList → 组装 ForwardingRule;所有写入方法(add/update/delete/reload)改为 `@Deprecated` 抛出 `UnsupportedOperationException` +- **原因**: 反代操作统一在宝塔面板进行,平台仅做列表查看 +- **结果**: 转发管理页面展示宝塔12个站点中的反代项目 + +### 4. CertificateService 改造 +- **做了什么**: `listCertificates()` 改为从宝塔 `get_orders` 拉 ACME 证书数据,合并本地数据库 `certificates` 表中的邮件通知配置;`issueCertificate()`/`renewCertificate()` 禁用 +- **修复**: 第一版未合并本地通知配置,修复后按域名匹配本地DB记录合并 `notifyEmail/notifyEnabled/notifyDaysBefore` +- **结果**: 证书列表从宝塔拉取,邮件通知配置可正常读写 + +### 5. Vue 前端只读改造 +- **NginxForwarding.vue**: + - 去掉"新增规则"和"重载Nginx"按钮,替换为提示标签"新增/修改/删除请在宝塔面板上操作" + - 操作列"修改"/"删除"改为"查看" + - 编辑弹窗改为只读详情弹窗,调用 `GET /api/rules/{id}/config` 加载配置文件内容和 SSL 证书信息 + - 移除配置文件Tab、SSL Tab、gzip Tab、日志Tab、LE签发弹窗 +- **CertManagement.vue**: 去掉"签发证书"和"一键续期"按钮,保留"通知设置" +- **结果**: 前端编译通过,部署到 nginx-web-nginx-web 容器 + +### 6. ForwardingRuleController 改造 +- **做了什么**: GET/POST/PUT/DELETE 接口调整,写入端点返回 405/410 错误并提示去宝塔操作;`GET /{id}/config` 改为返回配置文件内容 + SSL证书信息 +- **结果**: 编译通过 + +### 7. RestTemplateConfig 修改 +- **做了什么**: 新增 `btRestTemplate` Bean,使用 JDK 原生 `SSLContext` + `TrustManager` 跳过宝塔自签名证书校验 +- **原因**: 宝塔面板使用自签名 SSL 证书 + +### 8. 配置文件修改 +- **application.yml**: 新增 `bt-panel` 配置节(base-url: https://101.132.183.138:18888, api-key: ahv5RRcYm3Vi2PzEFvqYsZvsS8LeGkYp) +- **原因**: 宝塔 API 连接配置 + +## 重要信息 + +### 宝塔面板连接 +- 面板地址: https://101.132.183.138:18888 +- API Key: ahv5RRcYm3Vi2PzEFvqYsZvsS8LeGkYp +- 白名单IP: 183.192.136.97(10.1.1.170 出公网IP) + +### 部署架构(当前) +| 组件 | 机器 | 端口 | 说明 | +|------|------|------|------| +| Vue 前端 | 10.1.1.170 Docker (nginx-web-nginx-web) | 3002 | Nginx转发管理/SSL证书管理/DNS解析管理 | +| Java 后端 | 10.1.1.170 Docker (nginx-server-nginx-server) | 4002 | 宝塔API接入 + 阿里云DNS | +| Python API | 10.1.1.160 | 5000 | 仅DNS解析管理(阿里云API),nginx将卸载 | +| MySQL | 10.1.1.122 | 3306 | DNS审计记录 + 证书通知配置 | + +### 涉及文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `service/BTPanelApiClient.java` | **新增** | 宝塔面板 API 客户端(5个接口) | +| `service/ForwardingRuleService.java` | **重写** | 数据源从Python API切换至宝塔API | +| `service/CertificateService.java` | **重写** | 数据源从Python API切换至宝塔API | +| `controller/ForwardingRuleController.java` | **重写** | 只保留GET,写入返回405/410 | +| `controller/CertificateController.java` | **改** | 签发/续期返回405 | +| `config/RestTemplateConfig.java` | **改** | 新增btRestTemplate(跳过SSL校验) | +| `application.yml` | **改** | 新增bt-panel配置 | +| `views/NginxForwarding.vue` | **重写** | 只读模式,查看弹窗含配置+SSL | +| `views/CertManagement.vue` | **改** | 去掉签发/续期按钮 | + +## 待处理 +- [ ] 用户验证前端页面:转发管理列表 + 查看弹窗(配置文件 + SSL证书) +- [ ] 用户验证证书管理页面:邮件通知配置是否正常显示 +- [ ] 160 机器 nginx 卸载(用户决定时机) +- [ ] 宝塔面板上创建更多反代项目后验证列表自动刷新 diff --git a/memory/session-20260621-2.md b/memory/session-20260621-2.md new file mode 100644 index 0000000..679253b --- /dev/null +++ b/memory/session-20260621-2.md @@ -0,0 +1,94 @@ +--- +name: session-20260621-2 +description: 2026-06-21 第2次会话 — DNS解析API从10.1.1.160迁移至101.132.183.138:32210 +metadata: + type: session + date: 2026-06-21 + sessionNumber: 2 + startTime: 2026-06-21T03:45:00+08:00 + endTime: 2026-06-21T04:00:00+08:00 +--- + +# 2026-06-21 第2次会话 + +## 会话摘要 +将 DNS 解析 Python API 从 10.1.1.160:5000 迁移至 101.132.183.138:32210。创建了精简版独立 DNS API(去掉了 Nginx/SSL 依赖),通过 systemd 部署到 138 上,Java 后端改指新地址,已编译部署到 Docker。全链路验证通过。 + +## 关键操作 + +### 1. 创建独立 DNS Python API +- **做了什么**: 新建 `dns-api/` 目录,包含 4 个文件:`app.py`(精简 Flask,仅 DNS 路由+健康检查)、`dns_service.py`(复制自原项目,零依赖改动)、`requirements.txt`(flask 2.0.3 + gunicorn 20.1.0,兼容 Python 3.6)、`gunicorn_config.py`(端口 32210) +- **原因**: 10.1.1.160 将停用,138 上只需要 DNS 功能,不需要 Nginx/SSL 管理 +- **关键改动**: `zone` 默认值从 `ssl_service.get_le_root_domain()` 改为环境变量 `DNS_DEFAULT_ZONE`(默认 zgitm.com),彻底解除对 ssl_service 的依赖 +- **结果**: 4 个文件已上传至 138 + +### 2. 部署 DNS API 到 101.132.183.138 +- **做了什么**: SSH 到 138,安装依赖(flask 2.0.3 + gunicorn 20.1.0,适配 Python 3.6.8),创建 systemd 服务 `/etc/systemd/system/dns-api.service`,设置开机自启 +- **阿里云 AK**: `LTAI5t98jUxLUadZM1LkaSa8` / `K2HyLLXizAgvOMOa0Mzc7ebs4gbAZs`(从 160 systemd 文件获取,已写入 138 服务 Environment) +- **端口**: 32210(HTTP) +- **结果**: 服务正常运行,健康检查通过,DNS 记录查询正常 + +### 3. 修改 Java 后端配置 +- **application.yml**: `python-api.base-url` 从 `http://10.1.1.160:5000` → `http://101.132.183.138:32210` +- **原因**: Java 通过 RestTemplate rootUri 自动拼接路径,只需改配置 +- **无需改动**: RestTemplateConfig.java、PythonApiClient.java、DnsRecordService.java、DnsController.java、前端任何文件 + +### 4. 编译部署 Java 后端 +- **编译**: `mvn clean package -Pprod -DskipTests` → BUILD SUCCESS +- **部署**: JAR 上传到 10.1.1.170,docker cp 到 nginx-server-nginx-server:/app/app.jar,重启容器 +- **结果**: 服务正常启动,API 响应正常 + +### 5. 全链路验证 +- **列表查询**: 通过 Java → 138:32210 → 阿里云,记录正常返回(含审计字段) +- **添加测试**: `verify-dns-migration.zgitm.com` 创建成功,record_id 返回正确 +- **删除测试**: 测试记录已清理,列表确认已删除 +- **结果**: DNS 添加/删除/列表 全链路通过 + +## 重要信息 + +### 新 DNS API 部署信息 +| 项目 | 详情 | +|------|------| +| 服务器 | 101.132.183.138 | +| 端口 | 32210 | +| 协议 | HTTP | +| Python 版本 | 3.6.8 | +| 服务名 | dns-api | +| 目录 | /opt/dns-api/ | +| 启动 | `systemctl start dns-api` | +| 日志 | `journalctl -u dns-api` | +| 环境变量 | Ali_Key, Ali_Secret, DNS_DEFAULT_ZONE=zgitm.com | +| 默认 zone | zgitm.com(可通过 DNS_DEFAULT_ZONE 环境变量修改) | + +### 当前架构 +``` +Vue前端 → Mokee Gateway → Java后端(10.1.1.170:4002) + ↓ DnsRecordService + ↓ PythonApiClient + ↓ RestTemplate rootUri: http://101.132.183.138:32210 + ↓ + Python Flask (101.132.183.138:32210) + ↓ dns_service.py → 阿里云 DNS API (alidns.aliyuncs.com) +``` + +### 数据库地址变更(未完成) +- 用户要求将数据库从 10.1.1.122 改为 10.20.1.122 +- **问题**: 10.20.1.122 从 10.1.1.170(Docker 主机)ping 不通,3306 端口不可达 +- **处理**: 已回滚,仍使用 10.1.1.122 +- **待办**: 网络/防火墙打通后再次修改并部署 + +### 涉及文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `dns-api/app.py` | **新建** | 精简版 Flask DNS API(4个DNS路由+健康检查) | +| `dns-api/dns_service.py` | **新建** | 阿里云DNS服务核心(复制自原项目) | +| `dns-api/requirements.txt` | **新建** | flask==2.0.3, gunicorn==20.1.0 (Python 3.6兼容) | +| `dns-api/gunicorn_config.py` | **新建** | 端口32210 | +| `application.yml` | **改1行** | python-api.base-url → 101.132.183.138:32210 | +| `application-qa.yml` | 无净变更 | 数据库地址改了又回滚 | +| `application-prod.yml` | 无净变更 | 数据库地址改了又回滚 | + +## 待处理 +- [ ] 数据库地址 10.1.1.122 → 10.20.1.122(需网络打通 / 连得上数据库) +- [ ] 160 机器停用后清理旧 nginx-api 服务 diff --git a/nginx-manager-backend/Dockerfile b/nginx-manager-backend/Dockerfile new file mode 100644 index 0000000..ca6e8dd --- /dev/null +++ b/nginx-manager-backend/Dockerfile @@ -0,0 +1,5 @@ +FROM harbor.zgitm.com/library/eclipse-temurin:17-jre +WORKDIR /app +COPY target/*.jar app.jar +EXPOSE 32402 +ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file diff --git a/nginx-manager-backend/pom.xml b/nginx-manager-backend/pom.xml new file mode 100644 index 0000000..e8971da --- /dev/null +++ b/nginx-manager-backend/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.mokee + nginx-manager + 1.0.0 + Nginx Manager Backend + Nginx域名转发管理平台 - Spring Boot 后端 + + + 17 + + qa + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.9 + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + qa + + qa + + + true + + + + + + prod + + prod + + + + + + + + + src/main/resources + true + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + -Dspring.profiles.active=${spring.profiles.active} + + + org.projectlombok + lombok + + + + + + + diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/NginxManagerApplication.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/NginxManagerApplication.java new file mode 100644 index 0000000..d5e4024 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/NginxManagerApplication.java @@ -0,0 +1,17 @@ +package com.mokee.nginx; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Nginx域名转发管理平台 - Spring Boot 启动类 + */ +@SpringBootApplication +@EnableScheduling +public class NginxManagerApplication { + + public static void main(String[] args) { + SpringApplication.run(NginxManagerApplication.class, args); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/config/CorsConfig.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/CorsConfig.java new file mode 100644 index 0000000..2de88d3 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/CorsConfig.java @@ -0,0 +1,30 @@ +package com.mokee.nginx.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 跨域配置 — 允许网关域名的跨域请求 + * + * 网关转发请求时 origin 是浏览器地址(非网关地址), + * 业务后端必须开放跨域,否则浏览器拒绝响应。 + * + * 注意:allowCredentials=true 时不能使用 allowedOrigins("*"), + * 必须用 allowedOriginPatterns 指定具体模式。 + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns( + "https://*.zgitm.com", + "http://localhost:*" + ) + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/config/JacksonConfig.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/JacksonConfig.java new file mode 100644 index 0000000..0345571 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package com.mokee.nginx.config; + +import com.fasterxml.jackson.core.JsonGenerator; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Jackson 配置 — 中文不转义为 Unicode + */ +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> builder.featuresToDisable( + JsonGenerator.Feature.ESCAPE_NON_ASCII + ); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/config/MyMetaObjectHandler.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/MyMetaObjectHandler.java new file mode 100644 index 0000000..47f5975 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/MyMetaObjectHandler.java @@ -0,0 +1,55 @@ +package com.mokee.nginx.config; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.mokee.nginx.context.UserContext; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 自动填充处理器 + * 替代 JPA 的 @PrePersist / @PreUpdate + * 自动填充创建时间/更新时间/创建人/更新人 + */ +@Slf4j +@Component +public class MyMetaObjectHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now); + this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now); + + String operator = resolveOperator(); + this.strictInsertFill(metaObject, "createdBy", String.class, operator); + this.strictInsertFill(metaObject, "updatedBy", String.class, operator); + } + + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); + + String operator = resolveOperator(); + this.strictUpdateFill(metaObject, "updatedBy", String.class, operator); + } + + /** + * 从 UserContext 获取当前操作人 + * 优先级:userName > userId > "系统" + * 定时任务等无用户上下文的场景返回 "系统" + */ + private String resolveOperator() { + try { + String userName = UserContext.getUserName(); + if (userName != null && !userName.isEmpty()) return userName; + String userId = UserContext.getUserId(); + if (userId != null && !userId.isEmpty()) return userId; + } catch (Exception e) { + // UserContext ThreadLocal 可能未初始化(定时任务、测试等场景) + } + return "系统"; + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/config/RestTemplateConfig.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/RestTemplateConfig.java new file mode 100644 index 0000000..0310519 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/config/RestTemplateConfig.java @@ -0,0 +1,87 @@ +package com.mokee.nginx.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import javax.net.ssl.*; +import java.net.HttpURLConnection; +import java.net.URI; +import java.security.cert.X509Certificate; +import java.time.Duration; + +/** + * RestTemplate 配置,用于调用 Python API 和宝塔面板 API + */ +@Configuration +public class RestTemplateConfig { + + @Value("${python-api.base-url}") + private String pythonApiBaseUrl; + + @Value("${python-api.connect-timeout:5000}") + private int connectTimeout; + + @Value("${python-api.read-timeout:30000}") + private int readTimeout; + + @Value("${python-api.cert-read-timeout:210000}") + private int certReadTimeout; + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .rootUri(pythonApiBaseUrl) + .setConnectTimeout(Duration.ofMillis(connectTimeout)) + .setReadTimeout(Duration.ofMillis(readTimeout)) + .build(); + } + + /** 证书操作专用 RestTemplate(签发/续期超时: 5分钟) */ + @Bean + public RestTemplate certRestTemplate(RestTemplateBuilder builder) { + return builder + .rootUri(pythonApiBaseUrl) + .setConnectTimeout(Duration.ofMillis(connectTimeout)) + .setReadTimeout(Duration.ofMillis(certReadTimeout)) + .build(); + } + + /** 宝塔面板专用 RestTemplate(信任自签名证书,跳过证书校验) */ + @Bean + public RestTemplate btRestTemplate() throws Exception { + // 创建信任所有证书的 TrustManager + TrustManager[] trustAll = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + } + }; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAll, new java.security.SecureRandom()); + + // 设置默认 SSL 工厂 + 跳过主机名验证 + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); + HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + + // 使用 SimpleClientHttpRequestFactory(不依赖 Apache HttpClient) + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) { + if (connection instanceof HttpsURLConnection httpsConn) { + httpsConn.setSSLSocketFactory(sslContext.getSocketFactory()); + httpsConn.setHostnameVerifier((hostname, session) -> true); + } + } + }; + factory.setConnectTimeout(Duration.ofMillis(connectTimeout)); + factory.setReadTimeout(Duration.ofMillis(readTimeout)); + + return new RestTemplate(factory); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/context/UserContext.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/context/UserContext.java new file mode 100644 index 0000000..6422e2d --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/context/UserContext.java @@ -0,0 +1,38 @@ +package com.mokee.nginx.context; + +import java.util.*; + +/** + * 用户上下文 — ThreadLocal 缓存网关注入的请求头 + * + * 业务代码零侵入获取当前登录用户信息: + *
+ *   String userId   = UserContext.getUserId();
+ *   String userName = UserContext.getUserName();
+ * 
+ * + * 由 {@link com.mokee.nginx.filter.UserContextFilter} 在请求进入时设置, + * 请求结束后自动清理。 + */ +public class UserContext { + + private static final ThreadLocal> CTX = new ThreadLocal<>(); + + public static void set(Map user) { CTX.set(user); } + public static Map get() { + Map map = CTX.get(); + return map != null ? map : Collections.emptyMap(); + } + public static void clear() { CTX.remove(); } + + // 快捷方法 + public static String getUserId() { return get().get("X-User-Id"); } + public static String getUserName() { return get().get("X-User-Name"); } + public static String getUserAccount() { return get().get("X-User-Account"); } + public static String getUserEmail() { return get().get("X-User-Email"); } + public static String getUserPost() { return get().get("X-User-Post"); } + public static String getUserRoles() { return get().get("X-User-Roles"); } + public static String getSystemCode() { return get().get("X-System-Code"); } + public static String getSystemId() { return get().get("X-System-Id"); } + public static String getSystemName() { return get().get("X-System-Name"); } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/CertificateController.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/CertificateController.java new file mode 100644 index 0000000..d8e7d5e --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/CertificateController.java @@ -0,0 +1,82 @@ +package com.mokee.nginx.controller; + +import com.mokee.nginx.dto.ApiResponse; +import com.mokee.nginx.entity.Certificate; +import com.mokee.nginx.service.CertificateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * SSL证书管理 Controller + */ +@Slf4j +@RestController +@RequestMapping("/api/certificates") +public class CertificateController { + + @Autowired + private CertificateService certificateService; + + /** + * 获取所有证书记录 + */ + @GetMapping + public ResponseEntity>> listCertificates() { + log.info("获取证书列表"); + List certs = certificateService.listCertificates(); + return ResponseEntity.ok(ApiResponse.success(certs)); + } + + /** + * 获取单个证书详情 + */ + @GetMapping("/{id}") + public ResponseEntity> getCertificate(@PathVariable Long id) { + log.info("获取证书详情: id={}", id); + Certificate cert = certificateService.getCertificate(id); + return ResponseEntity.ok(ApiResponse.success(cert)); + } + + /** + * @deprecated 证书签发请在宝塔面板上操作 + */ + @PostMapping("/{id}/issue") + public ResponseEntity> issueCertificate(@PathVariable Long id) { + log.warn("签发证书请求被拒绝(请到宝塔面板操作): id={}", id); + return ResponseEntity.status(405).body( + ApiResponse.error(405, "证书签发请在宝塔面板上操作(SSL → Let's Encrypt)")); + } + + /** + * @deprecated 证书续期请在宝塔面板上操作 + */ + @PostMapping("/{id}/renew") + public ResponseEntity> renewCertificate(@PathVariable Long id) { + log.warn("续期证书请求被拒绝(请到宝塔面板操作): id={}", id); + return ResponseEntity.status(405).body( + ApiResponse.error(405, "证书续期请在宝塔面板上操作(SSL → 续期)")); + } + + /** + * 更新通知配置 + */ + @PutMapping("/{id}/notify-config") + public ResponseEntity> updateNotifyConfig( + @PathVariable Long id, @RequestBody Map body) { + log.info("更新通知配置: id={}", id); + String notifyEmail = (String) body.get("notifyEmail"); + Integer notifyEnabled = body.get("notifyEnabled") != null + ? ((Number) body.get("notifyEnabled")).intValue() : null; + Integer notifyDaysBefore = body.get("notifyDaysBefore") != null + ? ((Number) body.get("notifyDaysBefore")).intValue() : null; + + Certificate cert = certificateService.updateNotifyConfig( + id, notifyEmail, notifyEnabled, notifyDaysBefore); + return ResponseEntity.ok(ApiResponse.success("通知配置已更新", cert)); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/DnsController.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/DnsController.java new file mode 100644 index 0000000..2c3f6f6 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/DnsController.java @@ -0,0 +1,105 @@ +package com.mokee.nginx.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mokee.nginx.dto.ApiResponse; +import com.mokee.nginx.entity.DnsZoneConfig; +import com.mokee.nginx.mapper.DnsZoneConfigMapper; +import com.mokee.nginx.service.DnsRecordService; +import com.mokee.nginx.service.PythonApiClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * DNS 解析管理 Controller + * DNS 记录通过 DnsRecordService 管理(阿里云为权威数据源 + 本地审计追踪) + */ +@Slf4j +@RestController +@RequestMapping("/api/dns") +public class DnsController { + + @Autowired + private DnsRecordService dnsRecordService; + + @Autowired + private DnsZoneConfigMapper zoneConfigMapper; + + /** + * 获取 DNS Zone 配置列表 + */ + @GetMapping("/zones") + public ResponseEntity>> listZones() { + List zones = zoneConfigMapper.selectList(null); + return ResponseEntity.ok(ApiResponse.success(zones)); + } + + /** + * 获取 DNS 解析记录列表(含本地审计信息:创建人/时间/更新人/时间) + */ + @GetMapping("/records") + public ResponseEntity>>> listRecords( + @RequestParam(defaultValue = "zgitm.com") String zone) { + log.info("获取DNS记录: zone={}", zone); + List> records = dnsRecordService.listRecords(zone); + return ResponseEntity.ok(ApiResponse.success(records)); + } + + /** + * 添加 DNS 解析记录 + */ + @PostMapping("/records") + public ResponseEntity>> addRecord( + @RequestBody Map body) { + log.info("添加DNS记录: {}", body); + Map result = dnsRecordService.addRecord(body); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + /** + * 删除 DNS 解析记录 + */ + @DeleteMapping("/records/{recordId}") + public ResponseEntity>> deleteRecord( + @PathVariable String recordId) { + log.info("删除DNS记录: recordId={}", recordId); + Map result = dnsRecordService.deleteRecord(recordId); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + /** + * 修改 DNS 解析记录 + */ + @PutMapping("/records/{recordId}") + public ResponseEntity>> updateRecord( + @PathVariable String recordId, @RequestBody Map body) { + log.info("修改DNS记录: recordId={}, body={}", recordId, body); + Map result = dnsRecordService.updateRecord(recordId, body); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + /** + * 获取默认 zone 配置(前端默认值用) + */ + @GetMapping("/default-zone") + public ResponseEntity>> getDefaultZone() { + DnsZoneConfig zone = zoneConfigMapper.selectOne( + new LambdaQueryWrapper() + .eq(DnsZoneConfig::getDomainZone, "zgitm.com") + .eq(DnsZoneConfig::getStatus, 1)); + Map data = new HashMap<>(); + if (zone != null) { + data.put("zone", zone.getDomainZone()); + data.put("defaultIp", zone.getDefaultIp()); + } else { + data.put("zone", "zgitm.com"); + data.put("defaultIp", "10.20.1.160"); + } + return ResponseEntity.ok(ApiResponse.success(data)); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/ForwardingRuleController.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/ForwardingRuleController.java new file mode 100644 index 0000000..9afdc8f --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/ForwardingRuleController.java @@ -0,0 +1,133 @@ +package com.mokee.nginx.controller; + +import com.mokee.nginx.dto.ApiResponse; +import com.mokee.nginx.dto.RuleRequest; +import com.mokee.nginx.entity.ForwardingRule; +import com.mokee.nginx.service.ForwardingRuleService; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 转发规则 REST Controller(只读模式) + * 数据来源:宝塔面板 API + * 新增/修改/删除请在宝塔面板上操作 + */ +@Slf4j +@RestController +@RequestMapping("/api/rules") +public class ForwardingRuleController { + + @Autowired + private ForwardingRuleService service; + + /** + * 获取所有转发规则列表(从宝塔面板拉取) + */ + @GetMapping + public ResponseEntity>> listRules() { + log.info("获取转发规则列表(数据源: 宝塔面板)"); + List rules = service.listRules(); + return ResponseEntity.ok(ApiResponse.success(rules)); + } + + /** + * 根据ID获取规则详情(从当前列表中匹配) + */ + @GetMapping("/{id}") + public ResponseEntity> getRule(@PathVariable Long id) { + log.info("获取规则详情: id={}", id); + ForwardingRule rule = service.listRules().stream() + .filter(r -> r.getId().equals(id)) + .findFirst() + .orElseThrow(() -> new RuntimeException("规则不存在: id=" + id)); + return ResponseEntity.ok(ApiResponse.success(rule)); + } + + // ═══════════════════════════════════════════════════════════ + // 以下写操作已废弃 — 请在宝塔面板上操作 + // ═══════════════════════════════════════════════════════════ + + @PostMapping + public ResponseEntity> addRule(@Valid @RequestBody RuleRequest request) { + log.warn("新增规则请求被拒绝(请到宝塔面板操作): domain={}", request.getDomain()); + return ResponseEntity.status(405).body( + ApiResponse.error(405, "新增转发规则请在宝塔面板上操作(站点 → 反向代理)")); + } + + @PutMapping("/{id}") + public ResponseEntity> updateRule( + @PathVariable Long id, @Valid @RequestBody RuleRequest request) { + log.warn("修改规则请求被拒绝(请到宝塔面板操作): id={}", id); + return ResponseEntity.status(405).body( + ApiResponse.error(405, "修改转发规则请在宝塔面板上操作(站点 → 反向代理)")); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deleteRule(@PathVariable Long id) { + log.warn("删除规则请求被拒绝(请到宝塔面板操作): id={}", id); + return ResponseEntity.status(405).body( + ApiResponse.error(405, "删除转发规则请在宝塔面板上操作(站点 → 反向代理)")); + } + + @PostMapping("/reload") + public ResponseEntity> reloadNginx() { + log.warn("重载Nginx请求被拒绝(请到宝塔面板操作)"); + return ResponseEntity.status(405).body( + ApiResponse.error(405, "重载Nginx请在宝塔面板上操作")); + } + + @GetMapping("/status") + public ResponseEntity> getStatus() { + log.info("Nginx状态查询(已迁移至宝塔)"); + return ResponseEntity.ok(ApiResponse.success("Nginx 反代已迁移至宝塔面板管理")); + } + + /** + * 获取规则详情(配置文件内容 + SSL证书信息) + */ + @GetMapping("/{id}/config") + public ResponseEntity>> getConfig(@PathVariable Long id) { + log.info("获取规则详情: id={}", id); + Map result = service.getConfig(id); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + @PutMapping("/{id}/config") + public ResponseEntity> updateConfig( + @PathVariable Long id, @RequestBody Object body) { + return ResponseEntity.status(410).body( + ApiResponse.error(410, "配置文件编辑已不可用,请前往宝塔面板")); + } + + @GetMapping("/{id}/ssl") + public ResponseEntity> getSslFiles(@PathVariable Long id) { + return ResponseEntity.status(410).body( + ApiResponse.error(410, "SSL证书查看请前往宝塔面板")); + } + + @GetMapping("/{id}/gzip") + public ResponseEntity> getGzipConfig(@PathVariable Long id) { + return ResponseEntity.status(410).body( + ApiResponse.error(410, "内容压缩配置请前往宝塔面板查看")); + } + + @PutMapping("/{id}/gzip") + public ResponseEntity> updateGzipConfig( + @PathVariable Long id, @RequestBody Object body) { + return ResponseEntity.status(410).body( + ApiResponse.error(410, "内容压缩配置请前往宝塔面板修改")); + } + + @GetMapping("/{id}/logs") + public ResponseEntity> getDomainLogs( + @PathVariable Long id, @RequestParam(defaultValue = "200") int lines) { + return ResponseEntity.status(410).body( + ApiResponse.error(410, "日志查看请前往宝塔面板")); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/GlobalExceptionHandler.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..f70e5f4 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/controller/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package com.mokee.nginx.controller; + +import com.mokee.nginx.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 参数校验失败 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + log.warn("参数校验失败: {}", message); + return ResponseEntity.badRequest() + .body(ApiResponse.error(message)); + } + + /** + * 业务异常 + */ + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException ex) { + log.error("业务异常: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(ex.getMessage())); + } + + /** + * 其他未捕获异常 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception ex) { + log.error("系统异常: ", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("系统内部错误: " + ex.getMessage())); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/dto/ApiResponse.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/dto/ApiResponse.java new file mode 100644 index 0000000..f0ea17b --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/dto/ApiResponse.java @@ -0,0 +1,52 @@ +package com.mokee.nginx.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 统一响应结构 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private int code; + private String message; + private T data; + + public static ApiResponse success(T data) { + return ApiResponse.builder() + .code(0) + .message("success") + .data(data) + .build(); + } + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .code(0) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse error(int code, String message) { + return ApiResponse.builder() + .code(code) + .message(message) + .data(null) + .build(); + } + + public static ApiResponse error(String message) { + return ApiResponse.builder() + .code(-1) + .message(message) + .data(null) + .build(); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/dto/RuleRequest.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/dto/RuleRequest.java new file mode 100644 index 0000000..bf70b36 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/dto/RuleRequest.java @@ -0,0 +1,28 @@ +package com.mokee.nginx.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 新增/修改转发规则请求DTO + */ +@Data +public class RuleRequest { + + /** 域名 */ + @NotBlank(message = "域名不能为空") + @Pattern(regexp = "^[a-zA-Z0-9]([a-zA-Z0-9\\-\\.]*[a-zA-Z0-9])?$", + message = "域名格式不正确") + private String domain; + + /** 协议类型: http 或 https */ + @NotBlank(message = "协议类型不能为空") + @Pattern(regexp = "^(http|https)$", message = "协议类型必须为 http 或 https") + private String protocol; + + /** 转发目标地址 */ + @NotBlank(message = "转发目标地址不能为空") + @Pattern(regexp = "^https?://.+", message = "转发目标地址必须以 http:// 或 https:// 开头") + private String target; +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/Certificate.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/Certificate.java new file mode 100644 index 0000000..8239d0b --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/Certificate.java @@ -0,0 +1,91 @@ +package com.mokee.nginx.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * SSL证书实体 + */ +@TableName("certificates") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Certificate { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 域名 */ + @TableField("domain") + private String domain; + + /** 证书类型: internal_ca / letsencrypt */ + @TableField("cert_type") + @Builder.Default + private String certType = "internal_ca"; + + /** 证书文件路径 */ + @TableField("cert_path") + private String certPath; + + /** 私钥文件路径 */ + @TableField("key_path") + private String keyPath; + + /** 签发日期 */ + @TableField("issue_date") + private LocalDateTime issueDate; + + /** 过期日期 */ + @TableField("expire_date") + private LocalDateTime expireDate; + + /** 状态: active / expired / revoked */ + @TableField("status") + @Builder.Default + private String status = "active"; + + /** 通知收件人邮箱 */ + @TableField("notify_email") + private String notifyEmail; + + /** 是否启用到期通知 */ + @TableField("notify_enabled") + @Builder.Default + private Integer notifyEnabled = 1; + + /** 提前多少天开始通知 */ + @TableField("notify_days_before") + @Builder.Default + private Integer notifyDaysBefore = 15; + + /** 上次续期时间 */ + @TableField("last_renew_time") + private LocalDateTime lastRenewTime; + + /** 续期/签发日志 */ + @TableField("renew_log") + private String renewLog; + + /** 创建人 */ + @TableField(value = "created_by", fill = FieldFill.INSERT) + private String createdBy; + + /** 更新人 */ + @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE) + private String updatedBy; + + /** 创建时间 */ + @TableField(value = "created_at", fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** 更新时间 */ + @TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/DnsRecord.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/DnsRecord.java new file mode 100644 index 0000000..accfe53 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/DnsRecord.java @@ -0,0 +1,71 @@ +package com.mokee.nginx.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * DNS 解析记录本地审计实体 + * 阿里云 DNS 为权威数据源,本表仅做操作审计追踪 + */ +@TableName("dns_records") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DnsRecord { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 阿里云解析记录ID */ + @TableField("record_id") + private String recordId; + + /** 主域名(如 zgitm.com) */ + @TableField("domain_zone") + private String domainZone; + + /** 主机记录 */ + @TableField("rr") + private String rr; + + /** 记录类型: A / CNAME / TXT */ + @TableField("record_type") + private String recordType; + + /** 记录值(IP 或域名) */ + @TableField("record_value") + private String recordValue; + + /** TTL 生效时间(秒) */ + @TableField("ttl") + @Builder.Default + private Integer ttl = 600; + + /** 状态: 1-正常, 0-已删除(逻辑删除) */ + @TableField("status") + @TableLogic(value = "1", delval = "0") + @Builder.Default + private Integer status = 1; + + /** 创建人 */ + @TableField(value = "created_by", fill = FieldFill.INSERT) + private String createdBy; + + /** 更新人 */ + @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE) + private String updatedBy; + + /** 创建时间 */ + @TableField(value = "created_at", fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** 更新时间 */ + @TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/DnsZoneConfig.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/DnsZoneConfig.java new file mode 100644 index 0000000..ed9031a --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/DnsZoneConfig.java @@ -0,0 +1,59 @@ +package com.mokee.nginx.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * DNS Zone 配置实体 + */ +@TableName("dns_zone_config") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DnsZoneConfig { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 主域名 (如 zgitm.com) */ + @TableField("domain_zone") + private String domainZone; + + /** 默认解析 IP */ + @TableField("default_ip") + @Builder.Default + private String defaultIp = "10.20.1.160"; + + /** DNS 服务商 */ + @TableField("dns_provider") + @Builder.Default + private String dnsProvider = "aliyun"; + + /** 状态: 1-启用 0-停用 */ + @TableField("status") + @TableLogic(value = "1", delval = "0") + @Builder.Default + private Integer status = 1; + + /** 创建人 */ + @TableField(value = "created_by", fill = FieldFill.INSERT) + private String createdBy; + + /** 更新人 */ + @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE) + private String updatedBy; + + /** 创建时间 */ + @TableField(value = "created_at", fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** 更新时间 */ + @TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/ForwardingRule.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/ForwardingRule.java new file mode 100644 index 0000000..d90337b --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/entity/ForwardingRule.java @@ -0,0 +1,89 @@ +package com.mokee.nginx.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Nginx转发规则实体 + */ +@TableName("forwarding_rules") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ForwardingRule { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 域名 */ + @TableField("domain") + private String domain; + + /** 协议类型: http / https */ + @TableField("protocol") + private String protocol; + + /** 转发目标地址 (http://host:port 或 https://host:port) */ + @TableField("target") + private String target; + + /** Nginx配置文件路径 */ + @TableField("config_file") + private String configFile; + + /** SSL证书路径 */ + @TableField("ssl_cert") + private String sslCert; + + /** SSL私钥路径 */ + @TableField("ssl_key") + private String sslKey; + + /** gzip压缩开关: true-开启, false-关闭 */ + @TableField("gzip_enabled") + @Builder.Default + private Boolean gzipEnabled = false; + + /** gzip压缩MIME类型(空格分隔) */ + @TableField("gzip_types") + @Builder.Default + private String gzipTypes = "text/plain text/css text/xml text/javascript application/x-javascript application/json application/xml application/xml+rss application/vnd.ms-fontobject application/x-font-ttf application/x-font-opentype application/x-font-truetype"; + + /** gzip压缩级别 1-9 */ + @TableField("gzip_comp_level") + @Builder.Default + private Integer gzipCompLevel = 6; + + /** gzip最小压缩长度(可带k/m单位,如 "1000", "1k", "2m") */ + @TableField("gzip_min_length") + @Builder.Default + private String gzipMinLength = "1000"; + + /** 状态: 1-正常, 0-已删除 */ + @TableField("status") + @TableLogic(value = "1", delval = "0") + @Builder.Default + private Integer status = 1; + + /** 创建人 */ + @TableField(value = "created_by", fill = FieldFill.INSERT) + private String createdBy; + + /** 更新人 */ + @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE) + private String updatedBy; + + /** 创建时间 */ + @TableField(value = "created_at", fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** 更新时间 */ + @TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/filter/UserContextFilter.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/filter/UserContextFilter.java new file mode 100644 index 0000000..831564a --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/filter/UserContextFilter.java @@ -0,0 +1,52 @@ +package com.mokee.nginx.filter; + +import com.mokee.nginx.context.UserContext; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * 用户上下文过滤器 — 提取网关注入的请求头并缓存到 ThreadLocal + * + * 网关验证 Token 后在转发请求时注入以下请求头(可信任,无需再鉴权): + * X-User-Id, X-User-Name, X-User-Account, X-User-Email, + * X-User-Post, X-User-Roles, X-System-Id, X-System-Code, X-System-Name + */ +@Component +public class UserContextFilter implements Filter { + + private static final String[] HEADERS = { + "X-User-Id", "X-User-Name", "X-User-Account", + "X-User-Email", "X-User-Post", "X-User-Roles", + "X-System-Id", "X-System-Code", "X-System-Name" + }; + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + Map user = new HashMap<>(); + for (String h : HEADERS) { + String v = request.getHeader(h); + if (v != null) { + // 网关部分请求头值可能被 URL 编码(如中文用户名),解码后再存入 + try { + v = URLDecoder.decode(v, StandardCharsets.UTF_8); + } catch (Exception ignored) { } + user.put(h, v); + } + } + UserContext.set(user); + try { + chain.doFilter(req, res); + } finally { + // 请求结束,清理 ThreadLocal 防止内存泄漏 + UserContext.clear(); + } + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/CertificateMapper.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/CertificateMapper.java new file mode 100644 index 0000000..2e90e66 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/CertificateMapper.java @@ -0,0 +1,12 @@ +package com.mokee.nginx.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.nginx.entity.Certificate; +import org.apache.ibatis.annotations.Mapper; + +/** + * SSL证书 Mapper + */ +@Mapper +public interface CertificateMapper extends BaseMapper { +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/DnsRecordMapper.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/DnsRecordMapper.java new file mode 100644 index 0000000..a741408 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/DnsRecordMapper.java @@ -0,0 +1,12 @@ +package com.mokee.nginx.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.nginx.entity.DnsRecord; +import org.apache.ibatis.annotations.Mapper; + +/** + * DNS 记录本地审计 Mapper + */ +@Mapper +public interface DnsRecordMapper extends BaseMapper { +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/DnsZoneConfigMapper.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/DnsZoneConfigMapper.java new file mode 100644 index 0000000..fee61b0 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/DnsZoneConfigMapper.java @@ -0,0 +1,12 @@ +package com.mokee.nginx.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.nginx.entity.DnsZoneConfig; +import org.apache.ibatis.annotations.Mapper; + +/** + * DNS Zone 配置 Mapper + */ +@Mapper +public interface DnsZoneConfigMapper extends BaseMapper { +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/ForwardingRuleMapper.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/ForwardingRuleMapper.java new file mode 100644 index 0000000..aac3ceb --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/mapper/ForwardingRuleMapper.java @@ -0,0 +1,12 @@ +package com.mokee.nginx.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.nginx.entity.ForwardingRule; +import org.apache.ibatis.annotations.Mapper; + +/** + * Nginx转发规则 Mapper + */ +@Mapper +public interface ForwardingRuleMapper extends BaseMapper { +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/service/BTPanelApiClient.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/BTPanelApiClient.java new file mode 100644 index 0000000..686e9e7 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/BTPanelApiClient.java @@ -0,0 +1,230 @@ +package com.mokee.nginx.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; +import java.util.Map; + +/** + * 宝塔面板 API HTTP 客户端(只读) + * + * 接口清单(来自宝塔官方 API 文档 api-doc.pdf): + * - 站点列表: POST /data?action=getData&table=sites (参数: p, limit, type=-1) + * - 反代列表: POST /site?action=GetProxyList (参数: sitename) + * - 证书订单: POST /acme?action=get_orders + * + * 签名规则:request_token = MD5(request_time + MD5(api_key)) + */ +@Slf4j +@Service +public class BTPanelApiClient { + + @Value("${bt-panel.base-url}") + private String baseUrl; + + @Value("${bt-panel.api-key}") + private String apiKey; + + private final RestTemplate btRestTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BTPanelApiClient( + @org.springframework.beans.factory.annotation.Qualifier("btRestTemplate") RestTemplate btRestTemplate) { + this.btRestTemplate = btRestTemplate; + } + + // ═══════════════════════════════════════════════════════════ + // 签名工具 + // ═══════════════════════════════════════════════════════════ + + private String md5(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("MD5计算失败", e); + } + } + + /** + * 发送 POST 请求到宝塔 API(参数通过 URL query string 传递) + */ + private Map postRequest(String route, String action, + MultiValueMap extraParams) { + String url = buildUrl(route, action, extraParams); + log.debug("宝塔API请求: POST {}", url); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + // 发送空 body(参数都在URL里) + HttpEntity entity = new HttpEntity<>("", headers); + + try { + ResponseEntity> response = btRestTemplate.exchange( + url, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {}); + return response.getBody(); + } catch (Exception e) { + log.error("宝塔API请求失败: url={}, error={}", url, e.getMessage()); + throw new RuntimeException("宝塔API请求失败: " + e.getMessage()); + } + } + + /** + * 发送 POST 请求,返回列表 + */ + private List> postRequestForList(String route, String action, + MultiValueMap extraParams) { + String url = buildUrl(route, action, extraParams); + log.debug("宝塔API请求(List): POST {}", url); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + HttpEntity entity = new HttpEntity<>("", headers); + + try { + ResponseEntity>> response = btRestTemplate.exchange( + url, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {}); + return response.getBody(); + } catch (Exception e) { + log.error("宝塔API请求失败(List): url={}, error={}", url, e.getMessage()); + throw new RuntimeException("宝塔API请求失败: " + e.getMessage()); + } + } + + /** + * 构建带签名的 URL + */ + private String buildUrl(String route, String action, + MultiValueMap extraParams) { + String requestTime = String.valueOf(System.currentTimeMillis() / 1000); + String rawToken = requestTime + md5(apiKey); + String requestToken = md5(rawToken); + + StringBuilder url = new StringBuilder(baseUrl); + url.append(route); + url.append("?action=").append(action); + url.append("&request_time=").append(requestTime); + url.append("&request_token=").append(requestToken); + + if (extraParams != null) { + for (Map.Entry> entry : extraParams.entrySet()) { + for (String value : entry.getValue()) { + url.append("&").append(entry.getKey()).append("=").append(value); + } + } + } + + return url.toString(); + } + + // ═══════════════════════════════════════════════════════════ + // 业务接口 + // ═══════════════════════════════════════════════════════════ + + /** + * 获取所有站点列表 + * API: POST /data?action=getData&table=sites + * + * 返回: {"data": [{"id":12,"name":"xxx.com","status":"1","project_type":"proxy",...}]} + */ + public Map getSiteList() { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("table", "sites"); + params.add("p", "1"); + params.add("limit", "200"); + params.add("type", "-1"); + + Map result = postRequest("/data", "getData", params); + log.info("宝塔API: 获取站点列表成功"); + return result; + } + + /** + * 获取指定站点的反向代理列表 + * API: POST /site?action=GetProxyList + * + * 参数: sitename=域名 + * 返回: [{"proxyname":"...","proxydir":"/","proxysite":"http://...",...}] + */ + @SuppressWarnings("unchecked") + public List> getProxyList(String siteName) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("sitename", siteName); + + try { + List> result = postRequestForList("/site", "GetProxyList", params); + log.debug("宝塔API: 获取站点 {} 反代列表成功, 数量={}", siteName, + result != null ? result.size() : 0); + return result; + } catch (Exception e) { + log.warn("获取宝塔站点 {} 反代列表失败: {}", siteName, e.getMessage()); + return List.of(); + } + } + + /** + * 获取 ACME 证书订单列表 + * API: POST /acme?action=get_orders + * + * 返回: [{"domains":["xxx.com"],"expires":1780111135,"status":"valid",...}] + */ + @SuppressWarnings("unchecked") + public List> getCertOrders() { + try { + List> result = postRequestForList("/acme", "get_orders", null); + log.info("宝塔API: 获取证书订单列表成功, 数量={}", result != null ? result.size() : 0); + return result; + } catch (Exception e) { + log.error("获取宝塔证书订单列表失败: {}", e.getMessage()); + throw new RuntimeException("获取宝塔证书订单列表失败: " + e.getMessage()); + } + } + + /** + * 读取宝塔服务器上的文件内容 + * API: POST /files?action=GetFileBody + * + * @param filePath 文件绝对路径 (如 /www/server/panel/vhost/nginx/xxx.com.conf) + * @return 文件内容字符串 + */ + public String getFileContent(String filePath) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("path", filePath); + + Map result = postRequest("/files", "GetFileBody", params); + if (result != null && result.get("data") != null) { + return (String) result.get("data"); + } + return null; + } + + /** + * 获取指定域名的 Nginx 配置文件内容 + */ + public String getNginxConfig(String domain) { + String configPath = "/www/server/panel/vhost/nginx/" + domain + ".conf"; + try { + return getFileContent(configPath); + } catch (Exception e) { + log.warn("获取 {} 的 nginx 配置失败: {}", domain, e.getMessage()); + return null; + } + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/service/CertNotifyScheduler.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/CertNotifyScheduler.java new file mode 100644 index 0000000..315468e --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/CertNotifyScheduler.java @@ -0,0 +1,213 @@ +package com.mokee.nginx.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mokee.nginx.entity.Certificate; +import com.mokee.nginx.mapper.CertificateMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 证书到期邮件通知调度器 + * 每天 10:00 检查 Let's Encrypt 证书,到期前 N 天起连续发邮件提醒 + */ +@Slf4j +@Service +public class CertNotifyScheduler { + + @Autowired + private CertificateMapper certificateMapper; + + /** 网关 API 地址(可配置,不用 rootUri 的 RestTemplate) */ + private final RestTemplate gatewayRestTemplate; + + @Value("${gateway.base-url:https://gw.server.mokee.com}") + private String gatewayBaseUrl; + + @Value("${gateway.system-code:nginxServer}") + private String systemCode; + + public CertNotifyScheduler() { + this.gatewayRestTemplate = new RestTemplate(); + } + + /** + * 每天 10:00 执行证书到期检查并发送邮件 + */ + @Scheduled(cron = "0 0 10 * * ?") + public void checkAndNotify() { + log.info("=== 证书到期通知检查开始 ==="); + + List certs = certificateMapper.selectList( + new LambdaQueryWrapper() + .eq(Certificate::getCertType, "letsencrypt") + .eq(Certificate::getStatus, "active") + .eq(Certificate::getNotifyEnabled, 1) + .isNotNull(Certificate::getNotifyEmail)); + + if (certs.isEmpty()) { + log.info("无需通知的 Let's Encrypt 证书"); + return; + } + + String token = null; + int notifiedCount = 0; + + for (Certificate cert : certs) { + if (cert.getExpireDate() == null) { + log.warn("{}: 无过期日期信息,跳过", cert.getDomain()); + continue; + } + + long daysLeft = ChronoUnit.DAYS.between( + LocalDate.now(), + cert.getExpireDate().toLocalDate()); + + log.info("{}: 剩余 {} 天到期 (阈值: {}天前开始)", + cert.getDomain(), daysLeft, cert.getNotifyDaysBefore()); + + // 判断是否在通知窗口:到期前 notifyDaysBefore 天 ~ 到期前1天 + if (daysLeft > cert.getNotifyDaysBefore() || daysLeft <= 0) { + log.info("{}: 不在通知窗口内 (daysLeft={})", cert.getDomain(), daysLeft); + continue; + } + + // 获取 Token + if (token == null) { + token = fetchToken(); + } + if (token == null) { + log.error("无法获取网关 Token,跳过 {}", cert.getDomain()); + continue; + } + + // 发送邮件 + String subject = String.format("⚠️ SSL证书到期提醒 - %s (剩余%d天)", + cert.getDomain(), daysLeft); + String content = buildEmailHtml(cert.getDomain(), daysLeft, cert.getExpireDate()); + + boolean success = sendEmail(token, cert.getNotifyEmail(), subject, content); + if (success) { + token = null; // Token 已消费,下次需重新获取 + notifiedCount++; + log.info("{}: 邮件发送成功 → {}", cert.getDomain(), cert.getNotifyEmail()); + } else { + log.warn("{}: 邮件发送失败,Token 未消费可重试", cert.getDomain()); + } + } + + log.info("=== 证书到期通知完成: 发送 {} 封邮件 ===", notifiedCount); + } + + /** + * 从网关获取一次性 Token + */ + private String fetchToken() { + try { + String url = gatewayBaseUrl + "/zgapi/v1/open/token"; + Map body = new HashMap<>(); + body.put("systemCode", systemCode); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = gatewayRestTemplate.postForEntity( + url, request, Map.class); + + Map data = (Map) response.getBody(); + if (data != null && Integer.valueOf(200).equals(data.get("code"))) { + Map inner = (Map) data.get("data"); + String token = (String) inner.get("token"); + log.info("获取 Token 成功: {}", systemCode); + return token; + } else { + log.error("获取 Token 失败: {}", data); + return null; + } + } catch (Exception e) { + log.error("获取 Token 异常: {}", e.getMessage()); + return null; + } + } + + /** + * 通过网关发送邮件 + */ + private boolean sendEmail(String token, String to, String subject, String content) { + try { + String url = gatewayBaseUrl + "/zgapi/v1/open/email/send"; + Map body = new HashMap<>(); + body.put("to", to); + body.put("subject", subject); + body.put("content", content); + body.put("contentType", "html"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(token); + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = gatewayRestTemplate.postForEntity( + url, request, Map.class); + + Map data = response.getBody(); + return data != null && Integer.valueOf(200).equals(data.get("code")); + } catch (Exception e) { + log.error("发送邮件异常: {}", e.getMessage()); + return false; + } + } + + /** + * 生成 HTML 邮件内容 + */ + private String buildEmailHtml(String domain, long daysLeft, LocalDateTime expireDate) { + String color; + String level; + if (daysLeft <= 5) { + color = "#F56C6C"; level = "危险"; + } else if (daysLeft <= 10) { + color = "#E6A23C"; level = "警告"; + } else { + color = "#409EFF"; level = "提醒"; + } + + return String.format( + "" + + "" + + "
" + + "
" + + "

⚠️ SSL证书即将到期 [%s]

" + + "
" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
域名%s
到期时间%s
剩余天数%d 天
证书类型Let's Encrypt (阿里云DNS验证)
" + + "
" + + "

系统已配置 acme.sh 自动续期。" + + "如续期失败请检查阿里云 AccessKey 和网络连通性。

" + + "

此邮件由 Nginx域名管理平台自动发送

" + + "
", + color, level, domain, + expireDate.toLocalDate().toString(), + color, daysLeft); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/service/CertificateService.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/CertificateService.java new file mode 100644 index 0000000..c313889 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/CertificateService.java @@ -0,0 +1,208 @@ +package com.mokee.nginx.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mokee.nginx.entity.Certificate; +import com.mokee.nginx.mapper.CertificateMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * SSL证书管理服务(只读模式) + * 数据来源:宝塔面板 ACME 证书订单 API + * 签发/续期请在宝塔面板上操作 + */ +@Slf4j +@Service +public class CertificateService { + + @Autowired + private CertificateMapper certificateMapper; + + @Autowired + private BTPanelApiClient btPanelApiClient; + + /** + * 获取所有证书列表(宝塔数据 + 本地通知配置合并) + */ + public List listCertificates() { + List certs = new ArrayList<>(); + long index = 1; + + try { + List> orders = btPanelApiClient.getCertOrders(); + + if (orders == null || orders.isEmpty()) { + log.info("宝塔证书订单列表为空"); + return certs; + } + + log.info("宝塔证书订单数量: {}", orders.size()); + + for (Map order : orders) { + @SuppressWarnings("unchecked") + List domains = (List) order.get("domains"); + String domain = (domains != null && !domains.isEmpty()) + ? domains.get(0) : "未知域名"; + + // 过期时间(Unix时间戳) + Object expiresObj = order.get("cert_timeout"); + if (expiresObj == null) { + expiresObj = order.get("expires"); + } + LocalDateTime expireDate = null; + if (expiresObj instanceof Number) { + long expiresTs = ((Number) expiresObj).longValue(); + expireDate = LocalDateTime.ofInstant( + Instant.ofEpochSecond(expiresTs), ZoneId.of("Asia/Shanghai")); + } + + String orderStatus = String.valueOf(order.getOrDefault("status", "unknown")); + String status = "valid".equals(orderStatus) ? "active" : orderStatus; + + // 查找本地数据库中该域名的通知配置 + Certificate localCert = certificateMapper.selectOne( + new LambdaQueryWrapper() + .eq(Certificate::getDomain, domain)); + + Certificate cert = Certificate.builder() + .domain(domain) + .certType("letsencrypt") + .status(status) + .expireDate(expireDate) + .build(); + + // 合并本地通知配置 + if (localCert != null) { + cert.setId(localCert.getId()); + cert.setNotifyEmail(localCert.getNotifyEmail()); + cert.setNotifyEnabled(localCert.getNotifyEnabled()); + cert.setNotifyDaysBefore(localCert.getNotifyDaysBefore()); + } else { + cert.setId(index); + cert.setNotifyEnabled(0); // 默认未配置通知 + } + index++; + + certs.add(cert); + } + + log.info("从宝塔获取到 {} 条证书记录(已合并本地通知配置)", certs.size()); + } catch (Exception e) { + log.error("从宝塔获取证书列表失败: {}", e.getMessage()); + throw new RuntimeException("获取证书列表失败: " + e.getMessage()); + } + + return certs; + } + + /** + * 获取单个证书详情 + */ + public Certificate getCertificate(Long id) { + // 从列表中匹配(宝塔数据源下id是序号,非持久化) + List allCerts = listCertificates(); + return allCerts.stream() + .filter(c -> c.getId().equals(id)) + .findFirst() + .orElseThrow(() -> new RuntimeException("证书不存在: id=" + id)); + } + + /** + * 更新通知配置(本地数据库操作,不影响宝塔) + */ + @Transactional + public Certificate updateNotifyConfig(Long id, String notifyEmail, + Integer notifyEnabled, Integer notifyDaysBefore) { + // 通知配置存本地数据库,按域名匹配 + List allCerts = listCertificates(); + Certificate btCert = allCerts.stream() + .filter(c -> c.getId().equals(id)) + .findFirst() + .orElseThrow(() -> new RuntimeException("证书不存在: id=" + id)); + + // 查找或创建本地通知配置记录 + Certificate localCert = certificateMapper.selectOne( + new LambdaQueryWrapper() + .eq(Certificate::getDomain, btCert.getDomain())); + + if (localCert == null) { + localCert = new Certificate(); + localCert.setDomain(btCert.getDomain()); + localCert.setCertType(btCert.getCertType()); + localCert.setStatus(btCert.getStatus()); + localCert.setExpireDate(btCert.getExpireDate()); + } + + if (notifyEmail != null) { + localCert.setNotifyEmail(notifyEmail); + } + if (notifyEnabled != null) { + localCert.setNotifyEnabled(notifyEnabled); + } + if (notifyDaysBefore != null) { + if (notifyDaysBefore < 1 || notifyDaysBefore > 30) { + throw new RuntimeException("通知天数必须在 1~30 之间"); + } + localCert.setNotifyDaysBefore(notifyDaysBefore); + } + + if (localCert.getId() != null) { + certificateMapper.updateById(localCert); + } else { + certificateMapper.insert(localCert); + } + + log.info("通知配置已更新: domain={}, email={}, enabled={}, daysBefore={}", + localCert.getDomain(), localCert.getNotifyEmail(), + localCert.getNotifyEnabled(), localCert.getNotifyDaysBefore()); + return localCert; + } + + // ═══════════════════════════════════════════════════════════ + // 以下写操作已废弃 — 请在宝塔面板上操作 + // ═══════════════════════════════════════════════════════════ + + /** + * @deprecated 证书签发请在宝塔面板上操作 + */ + @Deprecated + public Certificate issueCertificate(Long id) { + throw new UnsupportedOperationException( + "证书签发请在宝塔面板上操作(SSL → Let's Encrypt)"); + } + + /** + * @deprecated 证书续期请在宝塔面板上操作 + */ + @Deprecated + public Certificate renewCertificate(Long id) { + throw new UnsupportedOperationException( + "证书续期请在宝塔面板上操作(SSL → 续期)"); + } + + /** + * @deprecated 证书记录同步已不再需要(数据源为宝塔API) + */ + @Deprecated + public void syncCertificateRecord(String domain, String certPath, String keyPath) { + log.info("syncCertificateRecord 已废弃(数据源已切换至宝塔API): domain={}", domain); + } + + /** + * @deprecated 缓存失效已不再需要(数据源为宝塔API) + */ + @Deprecated + public void invalidateCache() { + log.info("invalidateCache 已废弃(数据源已切换至宝塔API)"); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/service/DnsRecordService.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/DnsRecordService.java new file mode 100644 index 0000000..b892a48 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/DnsRecordService.java @@ -0,0 +1,208 @@ +package com.mokee.nginx.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mokee.nginx.entity.DnsRecord; +import com.mokee.nginx.mapper.DnsRecordMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * DNS 记录业务层 — 阿里云 DNS 为权威数据源,本地 dns_records 表仅做操作审计 + * + * 核心流程: + * - 列表查询:阿里云 API → 本地表按 record_id 合并审计字段 → 返回富化结果 + * - 新增/修改/删除:先操作阿里云 → 再同步本地审计表 + */ +@Slf4j +@Service +public class DnsRecordService { + + @Autowired + private DnsRecordMapper dnsRecordMapper; + + @Autowired + private PythonApiClient pythonApiClient; + + /** + * 获取 DNS 解析记录列表(含本地审计信息) + * + * @param zone 主域名,如 zgitm.com + * @return 富化后的记录列表,每条包含 createdBy/createdAt/updatedBy/updatedAt + */ + @SuppressWarnings("unchecked") + public List> listRecords(String zone) { + // 1. 从阿里云获取权威 DNS 记录 + Map aliyunResult = pythonApiClient.listDnsRecords(zone); + Object dataObj = aliyunResult.get("data"); + List> aliyunRecords; + if (dataObj instanceof List) { + aliyunRecords = (List>) dataObj; + } else if (dataObj instanceof Map) { + // 兼容 Python API 返回格式:{ data: { data: [...] } } + Object inner = ((Map) dataObj).get("data"); + if (inner instanceof List) { + aliyunRecords = (List>) inner; + } else { + aliyunRecords = new ArrayList<>(); + } + } else { + aliyunRecords = new ArrayList<>(); + } + + if (aliyunRecords.isEmpty()) { + return aliyunRecords; + } + + // 2. 查询本地审计表 + List recordIds = aliyunRecords.stream() + .map(r -> String.valueOf(r.get("record_id"))) + .filter(id -> !id.isEmpty()) + .collect(Collectors.toList()); + + Map auditMap = new HashMap<>(); + if (!recordIds.isEmpty()) { + List localRecords = dnsRecordMapper.selectList( + new LambdaQueryWrapper() + .in(DnsRecord::getRecordId, recordIds) + .eq(DnsRecord::getStatus, 1)); + auditMap = localRecords.stream() + .collect(Collectors.toMap(DnsRecord::getRecordId, r -> r, (a, b) -> a)); + } + + // 3. 合并审计字段 + for (Map record : aliyunRecords) { + String recordId = String.valueOf(record.get("record_id")); + DnsRecord audit = auditMap.get(recordId); + if (audit != null) { + record.put("createdBy", audit.getCreatedBy()); + record.put("createdAt", audit.getCreatedAt()); + record.put("updatedBy", audit.getUpdatedBy()); + record.put("updatedAt", audit.getUpdatedAt()); + } else { + record.put("createdBy", null); + record.put("createdAt", null); + record.put("updatedBy", null); + record.put("updatedAt", null); + } + } + + // 4. 按创建时间倒序排列(有审计数据的优先) + aliyunRecords.sort((a, b) -> { + Object ta = a.get("createdAt"); + Object tb = b.get("createdAt"); + if (ta == null && tb == null) return 0; + if (ta == null) return 1; + if (tb == null) return -1; + return String.valueOf(tb).compareTo(String.valueOf(ta)); + }); + + return aliyunRecords; + } + + /** + * 添加 DNS 解析记录(阿里云 + 本地审计) + */ + @Transactional + public Map addRecord(Map body) { + // 1. 调用阿里云 API 创建 + Map result = pythonApiClient.addDnsRecord(body); + + // 2. 提取 record_id 并存本地审计 + Object dataObj = result.get("data"); + String recordId = null; + if (dataObj instanceof Map) { + recordId = String.valueOf(((Map) dataObj).get("record_id")); + } + + if (recordId != null && !recordId.isEmpty() && !"null".equals(recordId)) { + DnsRecord localRecord = DnsRecord.builder() + .recordId(recordId) + .domainZone(String.valueOf(body.getOrDefault("zone", ""))) + .rr(String.valueOf(body.getOrDefault("rr", ""))) + .recordType(String.valueOf(body.getOrDefault("type", "A"))) + .recordValue(String.valueOf(body.getOrDefault("value", ""))) + .ttl(body.get("ttl") instanceof Integer ? (Integer) body.get("ttl") : 600) + .status(1) + .build(); + dnsRecordMapper.insert(localRecord); + log.info("DNS 审计记录已保存: recordId={}, rr={}.{}", recordId, localRecord.getRr(), localRecord.getDomainZone()); + } + + return result; + } + + /** + * 修改 DNS 解析记录(阿里云 + 本地审计) + */ + @Transactional + public Map updateRecord(String recordId, Map body) { + // 1. 调用阿里云 API 更新 + Map result = pythonApiClient.updateDnsRecord(recordId, body); + + // 2. 更新本地审计记录 + DnsRecord localRecord = dnsRecordMapper.selectOne( + new LambdaQueryWrapper() + .eq(DnsRecord::getRecordId, recordId) + .eq(DnsRecord::getStatus, 1)); + + if (localRecord != null) { + if (body.containsKey("rr")) { + localRecord.setRr(String.valueOf(body.get("rr"))); + } + if (body.containsKey("type")) { + localRecord.setRecordType(String.valueOf(body.get("type"))); + } + if (body.containsKey("value")) { + localRecord.setRecordValue(String.valueOf(body.get("value"))); + } + if (body.containsKey("ttl")) { + localRecord.setTtl(body.get("ttl") instanceof Integer ? (Integer) body.get("ttl") : 600); + } + // updatedBy/updatedAt 由 MyMetaObjectHandler 自动填充 + dnsRecordMapper.updateById(localRecord); + log.info("DNS 审计记录已更新: recordId={}", recordId); + } else { + // 本地无记录但有阿里云记录(可能是旧数据),尝试补建 + log.info("DNS 审计记录不存在,尝试补建: recordId={}", recordId); + // 从 body 推断信息 + DnsRecord newAudit = DnsRecord.builder() + .recordId(recordId) + .domainZone(String.valueOf(body.getOrDefault("zone", ""))) + .rr(String.valueOf(body.getOrDefault("rr", ""))) + .recordType(String.valueOf(body.getOrDefault("type", "A"))) + .recordValue(String.valueOf(body.getOrDefault("value", ""))) + .ttl(body.get("ttl") instanceof Integer ? (Integer) body.get("ttl") : 600) + .status(1) + .build(); + dnsRecordMapper.insert(newAudit); + } + + return result; + } + + /** + * 删除 DNS 解析记录(阿里云 + 本地软删除) + */ + @Transactional + public Map deleteRecord(String recordId) { + // 1. 调用阿里云 API 删除 + Map result = pythonApiClient.deleteDnsRecord(recordId); + + // 2. 本地软删除 + DnsRecord localRecord = dnsRecordMapper.selectOne( + new LambdaQueryWrapper() + .eq(DnsRecord::getRecordId, recordId) + .eq(DnsRecord::getStatus, 1)); + if (localRecord != null) { + dnsRecordMapper.deleteById(localRecord.getId()); + log.info("DNS 审计记录已软删除: recordId={}", recordId); + } + + return result; + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/service/ForwardingRuleService.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/ForwardingRuleService.java new file mode 100644 index 0000000..78b9f52 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/ForwardingRuleService.java @@ -0,0 +1,290 @@ +package com.mokee.nginx.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mokee.nginx.entity.ForwardingRule; +import com.mokee.nginx.mapper.ForwardingRuleMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 转发规则业务逻辑层(只读模式) + * 数据来源:宝塔面板 API + * 新增/修改/删除操作请在宝塔面板上操作 + */ +@Slf4j +@Service +public class ForwardingRuleService { + + @Autowired + private ForwardingRuleMapper forwardingRuleMapper; + + @Autowired + private BTPanelApiClient btPanelApiClient; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 获取所有转发规则(从宝塔面板拉取) + * + * 流程: + * 1. POST /data action=getData&table=sites → 获取站点列表 + * 2. 遍历站点:project_type="proxy" 的站点直接解析 project_config + * 3. 非 proxy 站点调 POST /site action=GetProxyList 检查是否有 nginx 级反代 + */ + @SuppressWarnings("unchecked") + public List listRules() { + List rules = new ArrayList<>(); + long index = 1; + + try { + // 1. 获取站点列表 + Map siteResult = btPanelApiClient.getSiteList(); + List> sites = siteResult != null + ? (List>) siteResult.get("data") + : null; + + if (sites == null || sites.isEmpty()) { + log.info("宝塔站点列表为空"); + return rules; + } + + log.info("宝塔站点数量: {}", sites.size()); + + // 2. 遍历每个站点 + for (Map site : sites) { + String siteName = (String) site.get("name"); + if (siteName == null || siteName.isEmpty()) continue; + + boolean isRunning = "1".equals(String.valueOf(site.getOrDefault("status", "1"))); + String projectType = (String) site.getOrDefault("project_type", ""); + + // 统一通过 GetProxyList 获取反代规则(适用于 proxy 类型和普通站点) + List> proxies = btPanelApiClient.getProxyList(siteName); + + if (proxies != null && !proxies.isEmpty()) { + for (Map proxy : proxies) { + ForwardingRule rule = buildRuleFromProxyData( + index++, siteName, site, proxy, isRunning); + rules.add(rule); + } + } else if ("proxy".equals(projectType)) { + // proxy 类型但 GetProxyList 为空:降级显示 + ForwardingRule rule = ForwardingRule.builder() + .id(index++) + .domain(siteName) + .protocol("https") + .target("[反代项目 - 点击查看详情]") + .status(isRunning ? 1 : 0) + .build(); + rules.add(rule); + } + // 非 proxy 且无反代规则的站点不显示 + } + + log.info("从宝塔获取到 {} 条转发规则", rules.size()); + } catch (Exception e) { + log.error("从宝塔获取转发规则失败: {}", e.getMessage()); + throw new RuntimeException("获取转发规则失败: " + e.getMessage()); + } + + return rules; + } + + /** + * 从宝塔"反向代理项目"中解析转发规则 + * project_config 包含: proxy_pass, domain_list, https_port 等 + */ + private ForwardingRule buildRuleFromProxyProject(long id, String siteName, + Map site, boolean isRunning) { + try { + String configJson = (String) site.get("project_config"); + if (configJson == null || configJson.isEmpty()) return null; + + Map config = objectMapper.readValue(configJson, Map.class); + + // 提取目标地址 + String proxyPass = (String) config.get("proxy_pass"); + if (proxyPass == null || proxyPass.isEmpty()) { + // 可能存的是 proxy_dir 格式 + Object proxyDirObj = config.get("proxy_dir"); + if (proxyDirObj instanceof List && !((List) proxyDirObj).isEmpty()) { + Map firstProxy = (Map) ((List) proxyDirObj).get(0); + proxyPass = (String) firstProxy.get("proxy_pass"); + } + } + + String target = proxyPass != null ? proxyPass : "未知目标"; + + // 协议判断:有 https_port 配置则为 https + String httpsPort = (String) config.get("https_port"); + String protocol = (httpsPort != null && !httpsPort.isEmpty()) ? "https" : "http"; + + return ForwardingRule.builder() + .id(id) + .domain(siteName) + .protocol(protocol) + .target(target) + .status(isRunning ? 1 : 0) + .build(); + } catch (Exception e) { + log.warn("解析 {} 的 project_config 失败: {}", siteName, e.getMessage()); + // 降级:仍然显示站点,target 标记为未知 + return ForwardingRule.builder() + .id(id) + .domain(siteName) + .protocol("http") + .target("解析失败,请查看宝塔面板") + .status(isRunning ? 1 : 0) + .build(); + } + } + + /** + * 从 nginx 级反向代理数据中组装规则 + */ + private ForwardingRule buildRuleFromProxyData(long id, String siteName, + Map site, Map proxy, boolean isRunning) { + + String proxysite = (String) proxy.getOrDefault("proxysite", ""); + String proxydir = (String) proxy.getOrDefault("proxydir", "/"); + + String protocol = proxysite.startsWith("https://") ? "https" : "http"; + String target = proxysite; + if (!"/".equals(proxydir) && proxydir != null && !proxydir.isEmpty()) { + target = proxysite + " (路径: " + proxydir + ")"; + } + + return ForwardingRule.builder() + .id(id) + .domain(siteName) + .protocol(protocol) + .target(target) + .status(isRunning ? 1 : 0) + .build(); + } + + // ═══════════════════════════════════════════════════════════ + // 以下写操作已废弃 + // ═══════════════════════════════════════════════════════════ + + @Deprecated + public ForwardingRule addRule(Object request) { + throw new UnsupportedOperationException("新增转发规则请在宝塔面板上操作"); + } + + @Deprecated + public ForwardingRule updateRule(Long id, Object request) { + throw new UnsupportedOperationException("修改转发规则请在宝塔面板上操作"); + } + + @Deprecated + public void deleteRule(Long id) { + throw new UnsupportedOperationException("删除转发规则请在宝塔面板上操作"); + } + + @Deprecated + public void reloadNginx() { + throw new UnsupportedOperationException("重载Nginx请在宝塔面板上操作"); + } + + /** + * 获取规则详情:配置文件内容 + SSL证书信息 + */ + public Map getConfig(Long id) { + // 从列表中匹配域名 + List rules = listRules(); + ForwardingRule rule = rules.stream() + .filter(r -> r.getId().equals(id)) + .findFirst() + .orElseThrow(() -> new RuntimeException("规则不存在: id=" + id)); + + String domain = rule.getDomain(); + Map result = new java.util.LinkedHashMap<>(); + result.put("domain", domain); + result.put("protocol", rule.getProtocol()); + result.put("target", rule.getTarget()); + + // 获取 nginx 配置文件内容 + try { + String configContent = btPanelApiClient.getNginxConfig(domain); + result.put("configContent", configContent != null ? configContent : ""); + } catch (Exception e) { + log.warn("获取 {} 配置文件失败: {}", domain, e.getMessage()); + result.put("configContent", ""); + } + + // 获取 SSL 证书信息 + try { + List> orders = btPanelApiClient.getCertOrders(); + Map matchedCert = null; + if (orders != null) { + for (Map order : orders) { + @SuppressWarnings("unchecked") + List domains = (List) order.get("domains"); + if (domains != null && domains.contains(domain)) { + matchedCert = order; + break; + } + } + } + if (matchedCert != null) { + result.put("sslStatus", String.valueOf(matchedCert.getOrDefault("status", "unknown"))); + Object expiresObj = matchedCert.get("cert_timeout"); + if (expiresObj == null) expiresObj = matchedCert.get("expires"); + if (expiresObj instanceof Number) { + long expiresTs = ((Number) expiresObj).longValue(); + java.time.LocalDateTime expireDate = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochSecond(expiresTs), + java.time.ZoneId.of("Asia/Shanghai")); + result.put("sslExpireDate", expireDate.toString()); + long daysLeft = java.time.Duration.between( + java.time.LocalDateTime.now(), expireDate).toDays(); + result.put("sslDaysLeft", daysLeft); + } + result.put("sslAuthType", String.valueOf(matchedCert.getOrDefault("auth_type", ""))); + } else { + result.put("sslStatus", "none"); + } + } catch (Exception e) { + log.warn("获取 {} SSL信息失败: {}", domain, e.getMessage()); + result.put("sslStatus", "error"); + } + + return result; + } + + @Deprecated + public Map updateConfig(Long id, String content) { + throw new UnsupportedOperationException("配置文件编辑已不可用"); + } + + @Deprecated + public Map getSslFiles(Long id) { + throw new UnsupportedOperationException("SSL证书查看请前往宝塔面板"); + } + + @Deprecated + public Map getGzipConfig(Long id) { + throw new UnsupportedOperationException("内容压缩配置请前往宝塔面板查看"); + } + + @Deprecated + public Map updateGzipConfig(Long id, Map gzipConfig) { + throw new UnsupportedOperationException("内容压缩配置请前往宝塔面板修改"); + } + + @Deprecated + public Map getDomainLogs(Long id, int lines) { + throw new UnsupportedOperationException("日志查看请前往宝塔面板"); + } + + @Deprecated + public Map getNginxStatus() { + throw new UnsupportedOperationException("Nginx状态请前往宝塔面板查看"); + } +} diff --git a/nginx-manager-backend/src/main/java/com/mokee/nginx/service/PythonApiClient.java b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/PythonApiClient.java new file mode 100644 index 0000000..8d33eb9 --- /dev/null +++ b/nginx-manager-backend/src/main/java/com/mokee/nginx/service/PythonApiClient.java @@ -0,0 +1,421 @@ +package com.mokee.nginx.service; + +import com.mokee.nginx.dto.RuleRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +/** + * Python API HTTP 客户端 + * 调用 10.1.1.160:5000 上的 Nginx 管理 API + */ +@Slf4j +@Service +public class PythonApiClient { + + @Autowired + private RestTemplate restTemplate; + + /** 证书操作专用(签发/续期超时 5分钟) */ + @Autowired + @org.springframework.beans.factory.annotation.Qualifier("certRestTemplate") + private RestTemplate certRestTemplate; + + /** + * 获取所有Nginx转发规则 + */ + public Map getRules() { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/rules", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + return response.getBody(); + } catch (Exception e) { + log.error("调用Python API获取规则列表失败: {}", e.getMessage()); + throw new RuntimeException("无法连接到Nginx管理服务: " + e.getMessage()); + } + } + + /** + * 新增转发规则(HTTPS 可能触发 LE 签发,用长超时 RestTemplate) + */ + public Map addRule(RuleRequest request) { + try { + Map body = Map.of( + "domain", request.getDomain(), + "protocol", request.getProtocol(), + "target", request.getTarget() + ); + // HTTPS + zgitm 域名会触发 LE 签发(40s+),用长超时 + RestTemplate rt = "https".equals(request.getProtocol()) ? certRestTemplate : restTemplate; + ResponseEntity> response = rt.exchange( + "/api/nginx/rules", + HttpMethod.POST, + new HttpEntity<>(body), + new ParameterizedTypeReference<>() {} + ); + return response.getBody(); + } catch (Exception e) { + log.error("调用Python API新增规则失败: {}", e.getMessage()); + throw new RuntimeException("新增Nginx规则失败: " + e.getMessage()); + } + } + + /** + * 修改转发规则(HTTPS 可能触发 LE 签发,用长超时 RestTemplate) + */ + public Map updateRule(String domain, RuleRequest request) { + try { + Map body = Map.of( + "domain", request.getDomain(), + "protocol", request.getProtocol(), + "target", request.getTarget() + ); + RestTemplate rt = "https".equals(request.getProtocol()) ? certRestTemplate : restTemplate; + ResponseEntity> response = rt.exchange( + "/api/nginx/rules/{domain}", + HttpMethod.PUT, + new HttpEntity<>(body), + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("调用Python API修改规则失败: {}", e.getMessage()); + throw new RuntimeException("修改Nginx规则失败: " + e.getMessage()); + } + } + + /** + * 删除转发规则 + */ + public Map deleteRule(String domain) { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/rules/{domain}", + HttpMethod.DELETE, + null, + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("调用Python API删除规则失败: {}", e.getMessage()); + throw new RuntimeException("删除Nginx规则失败: " + e.getMessage()); + } + } + + /** + * 重载Nginx配置 + */ + public Map reloadNginx() { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/reload", + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {} + ); + return response.getBody(); + } catch (Exception e) { + log.error("调用Python API重载Nginx失败: {}", e.getMessage()); + throw new RuntimeException("重载Nginx配置失败: " + e.getMessage()); + } + } + + /** + * 获取指定域名的 Nginx 配置文件原始内容 + */ + public Map getRuleConfig(String domain) { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/rules/{domain}/config", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("获取配置文件失败: {}", e.getMessage()); + throw new RuntimeException("获取配置文件失败: " + e.getMessage()); + } + } + + /** + * 直接写入 Nginx 配置文件(会触发语法检查和重载,失败自动回滚) + */ + public Map updateRuleConfig(String domain, String content) { + try { + Map body = Map.of("content", content); + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/rules/{domain}/config", + HttpMethod.PUT, + new HttpEntity<>(body), + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("更新配置文件失败: {}", e.getMessage()); + throw new RuntimeException("更新配置文件失败: " + e.getMessage()); + } + } + + /** + * 获取指定域名的 SSL 证书和密钥文件内容 + */ + public Map getSslFiles(String domain) { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/rules/{domain}/ssl", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("获取SSL文件失败: {}", e.getMessage()); + throw new RuntimeException("获取SSL文件失败: " + e.getMessage()); + } + } + + // ═══════════════════════════════════════════════════════════ + // 内容压缩(gzip)API + // ═══════════════════════════════════════════════════════════ + + /** + * 获取指定域名的 gzip 压缩配置 + */ + public Map getGzipConfig(String domain) { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/rules/{domain}/gzip", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("获取gzip配置失败: {}", e.getMessage()); + throw new RuntimeException("获取gzip配置失败: " + e.getMessage()); + } + } + + /** + * 更新指定域名的 gzip 压缩配置 + */ + public Map updateGzipConfig(String domain, Map gzipConfig) { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/rules/{domain}/gzip", + HttpMethod.PUT, + new HttpEntity<>(gzipConfig), + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("更新gzip配置失败: {}", e.getMessage()); + throw new RuntimeException("更新gzip配置失败: " + e.getMessage()); + } + } + + /** + * 获取指定域名的 Nginx 日志 + */ + public Map getDomainLogs(String domain, int lines) { + try { + String path = "/api/nginx/rules/" + domain + "/logs?lines=" + lines; + ResponseEntity> response = restTemplate.exchange( + path, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + return response.getBody(); + } catch (Exception e) { + log.error("获取域名日志失败: {}", e.getMessage()); + throw new RuntimeException("获取域名日志失败: " + e.getMessage()); + } + } + + /** + * 健康检查 + */ + public boolean healthCheck() { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/health", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + Map body = response.getBody(); + return body != null && Integer.valueOf(0).equals(body.get("code")); + } catch (Exception e) { + log.warn("Python API健康检查失败: {}", e.getMessage()); + return false; + } + } + + // ═══════════════════════════════════════════════════════════ + // 证书管理 API(Let's Encrypt) + // ═══════════════════════════════════════════════════════════ + + /** + * 获取单个域名证书信息 + */ + public Map getCertInfo(String domain) { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/certs/{domain}", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}, + domain + ); + Map body = response.getBody(); + return body != null ? (Map) body.get("data") : null; + } catch (Exception e) { + log.error("获取证书信息失败: {}", e.getMessage()); + throw new RuntimeException("获取证书信息失败: " + e.getMessage()); + } + } + + /** + * 签发 Let's Encrypt 证书(超时: 5分钟) + */ + public Map issueCert(String domain) { + try { + ResponseEntity> response = certRestTemplate.exchange( + "/api/nginx/certs/{domain}/issue", + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("签发证书失败: {}", e.getMessage()); + throw new RuntimeException("签发证书失败: " + e.getMessage()); + } + } + + /** + * 续期 Let's Encrypt 证书(超时: 5分钟) + */ + public Map renewCert(String domain) { + try { + ResponseEntity> response = certRestTemplate.exchange( + "/api/nginx/certs/{domain}/renew", + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {}, + domain + ); + return response.getBody(); + } catch (Exception e) { + log.error("续期证书失败: {}", e.getMessage()); + throw new RuntimeException("续期证书失败: " + e.getMessage()); + } + } + + /** + * 获取所有 LE 证书信息列表 + */ + public Map listCerts() { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/certs", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + return response.getBody(); + } catch (Exception e) { + log.error("获取证书列表失败: {}", e.getMessage()); + throw new RuntimeException("获取证书列表失败: " + e.getMessage()); + } + } + + // ═══════════════════════════════════════════════════════════ + // DNS 解析管理 API + // ═══════════════════════════════════════════════════════════ + + /** + * 获取 DNS 记录列表 + */ + public Map listDnsRecords(String zone) { + try { + String path = "/api/nginx/dns/records?zone=" + zone; + ResponseEntity> response = restTemplate.exchange( + path, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + return response.getBody(); + } catch (Exception e) { + log.error("获取DNS记录失败: {}", e.getMessage()); + throw new RuntimeException("获取DNS记录失败: " + e.getMessage()); + } + } + + /** + * 添加 DNS 记录 + */ + public Map addDnsRecord(Map body) { + try { + HttpEntity> request = new HttpEntity<>(body); + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/dns/records", HttpMethod.POST, request, + new ParameterizedTypeReference<>() {}); + return response.getBody(); + } catch (Exception e) { + log.error("添加DNS记录失败: {}", e.getMessage()); + throw new RuntimeException("添加DNS记录失败: " + e.getMessage()); + } + } + + /** + * 删除 DNS 记录 + */ + public Map deleteDnsRecord(String recordId) { + try { + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/dns/records/{recordId}", HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {}, recordId); + return response.getBody(); + } catch (Exception e) { + log.error("删除DNS记录失败: {}", e.getMessage()); + throw new RuntimeException("删除DNS记录失败: " + e.getMessage()); + } + } + + /** + * 修改 DNS 记录 + */ + public Map updateDnsRecord(String recordId, Map body) { + try { + HttpEntity> request = new HttpEntity<>(body); + ResponseEntity> response = restTemplate.exchange( + "/api/nginx/dns/records/{recordId}", HttpMethod.PUT, request, + new ParameterizedTypeReference<>() {}, recordId); + return response.getBody(); + } catch (Exception e) { + log.error("修改DNS记录失败: {}", e.getMessage()); + throw new RuntimeException("修改DNS记录失败: " + e.getMessage()); + } + } +} diff --git a/nginx-manager-backend/src/main/resources/application-prod.yml b/nginx-manager-backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..fb7471c --- /dev/null +++ b/nginx-manager-backend/src/main/resources/application-prod.yml @@ -0,0 +1,16 @@ +# ========================================== +# PROD 生产环境配置 +# 激活方式: mvn spring-boot:run -Pprod +# 或 java -jar xxx.jar --spring.profiles.active=prod +# ========================================== +spring: + datasource: + url: jdbc:mysql://10.1.1.122:3306/nginxserver?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: nginxserver + password: mokee. + driver-class-name: com.mysql.cj.jdbc.Driver + +logging: + level: + com.mokee.nginx: INFO + org.springframework.web: WARN diff --git a/nginx-manager-backend/src/main/resources/application-qa.yml b/nginx-manager-backend/src/main/resources/application-qa.yml new file mode 100644 index 0000000..53ac503 --- /dev/null +++ b/nginx-manager-backend/src/main/resources/application-qa.yml @@ -0,0 +1,15 @@ +# ========================================== +# QA 测试环境配置 +# 激活方式: mvn spring-boot:run -Pqa +# 或 java -jar xxx.jar --spring.profiles.active=qa +# ========================================== +spring: + datasource: + url: jdbc:mysql://10.1.1.122:3306/nginxserver?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: nginxserver + password: mokee. + driver-class-name: com.mysql.cj.jdbc.Driver + +logging: + level: + com.mokee.nginx: DEBUG diff --git a/nginx-manager-backend/src/main/resources/application.yml b/nginx-manager-backend/src/main/resources/application.yml new file mode 100644 index 0000000..65c5615 --- /dev/null +++ b/nginx-manager-backend/src/main/resources/application.yml @@ -0,0 +1,47 @@ +# ========================================== +# 基础配置(所有环境共享) +# ========================================== +server: + port: 32402 + +spring: + application: + name: nginx-manager + + # 由 pom.xml Maven profile 控制,编译时替换:qa 或 prod + profiles: + active: @spring.profiles.active@ + +# Python Nginx API 连接配置(所有环境指向同一台 Nginx 服务器) +python-api: + base-url: http://101.132.183.138:32210 + connect-timeout: 5000 + read-timeout: 30000 + cert-read-timeout: 300000 + +# 宝塔面板 API 配置(只读:仅获取反代列表和证书列表) +bt-panel: + base-url: https://101.132.183.138:18888 + api-key: ahv5RRcYm3Vi2PzEFvqYsZvsS8LeGkYp + +# Mokee Gateway 对外开放 API 配置(证书邮件通知用) +gateway: + base-url: https://gw.server.zgitm.com + system-code: nginxServer + +# MyBatis-Plus 配置 +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + type-aliases-package: com.mokee.nginx.entity + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + global-config: + db-config: + id-type: auto + +# 日志配置 +logging: + level: + com.mokee.nginx: DEBUG + org.springframework.web: INFO diff --git a/nginx-manager-frontend/.env.development b/nginx-manager-frontend/.env.development new file mode 100644 index 0000000..a55fad8 --- /dev/null +++ b/nginx-manager-frontend/.env.development @@ -0,0 +1,5 @@ +# 开发环境 - 网关统一登录 +VITE_GATEWAY_URL = https://gw.server.zgitm.com +VITE_LOGIN_URL = https://login.user.zgitm.com +VITE_SYSTEM_CODE = nginxServer +VITE_APP_TITLE = Nginx转发管理平台 diff --git a/nginx-manager-frontend/.env.production b/nginx-manager-frontend/.env.production new file mode 100644 index 0000000..7aae63c --- /dev/null +++ b/nginx-manager-frontend/.env.production @@ -0,0 +1,5 @@ +# 生产环境 - 网关统一登录 +VITE_GATEWAY_URL = https://gw.server.zgitm.com +VITE_LOGIN_URL = https://login.user.zgitm.com +VITE_SYSTEM_CODE = nginxServer +VITE_APP_TITLE = Nginx转发管理平台 diff --git a/nginx-manager-frontend/.gitignore b/nginx-manager-frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/nginx-manager-frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/nginx-manager-frontend/.vscode/extensions.json b/nginx-manager-frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/nginx-manager-frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/nginx-manager-frontend/Dockerfile b/nginx-manager-frontend/Dockerfile new file mode 100644 index 0000000..3678599 --- /dev/null +++ b/nginx-manager-frontend/Dockerfile @@ -0,0 +1,4 @@ +FROM harbor.zgitm.com/library/nginx:alpine +COPY dist/ /usr/share/nginx/html/ +COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 \ No newline at end of file diff --git a/nginx-manager-frontend/README.md b/nginx-manager-frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/nginx-manager-frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/nginx-manager-frontend/nginx/nginx.conf b/nginx-manager-frontend/nginx/nginx.conf new file mode 100644 index 0000000..97063c3 --- /dev/null +++ b/nginx-manager-frontend/nginx/nginx.conf @@ -0,0 +1,52 @@ +# ========================================== +# Vue 项目 Nginx 配置模板 +# 配合 Dockerfile.vue 使用 +# ========================================== + +server { + listen 80; + server_name localhost; + + # 日志格式 + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # 站点根目录 + root /usr/share/nginx/html; + index index.html; + + # 静态资源长期缓存 + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Vue Router history 模式支持 + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理(如果有后端代理需求,可在此配置) + # location /api/ { + # proxy_pass http://backend-server:8080/; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # } + + # Gzip 压缩 + gzip on; + gzip_vary on; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/x-javascript + image/svg+xml; + gzip_min_length 1024; + gzip_proxied any; +} diff --git a/nginx-manager-frontend/package-lock.json b/nginx-manager-frontend/package-lock.json new file mode 100644 index 0000000..f2b62e0 --- /dev/null +++ b/nginx-manager-frontend/package-lock.json @@ -0,0 +1,1794 @@ +{ + "name": "nginx-manager-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nginx-manager-frontend", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.17.0", + "element-plus": "^2.14.1", + "vue": "^3.5.34", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.35", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", + "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", + "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", + "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/runtime-core": "3.5.35", + "@vue/shared": "3.5.35", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", + "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "vue": "3.5.35" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz", + "integrity": "sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.1" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", + "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-sfc": "3.5.35", + "@vue/runtime-dom": "3.5.35", + "@vue/server-renderer": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.4.tgz", + "integrity": "sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/nginx-manager-frontend/package.json b/nginx-manager-frontend/package.json new file mode 100644 index 0000000..cac1c9f --- /dev/null +++ b/nginx-manager-frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "nginx-manager-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.17.0", + "element-plus": "^2.14.1", + "vue": "^3.5.34", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "vite": "^5.4.0" + } +} \ No newline at end of file diff --git a/nginx-manager-frontend/public/favicon.svg b/nginx-manager-frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/nginx-manager-frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nginx-manager-frontend/public/icons.svg b/nginx-manager-frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/nginx-manager-frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nginx-manager-frontend/src/App.vue b/nginx-manager-frontend/src/App.vue new file mode 100644 index 0000000..1a74f32 --- /dev/null +++ b/nginx-manager-frontend/src/App.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/nginx-manager-frontend/src/api/nginxApi.js b/nginx-manager-frontend/src/api/nginxApi.js new file mode 100644 index 0000000..fbc2773 --- /dev/null +++ b/nginx-manager-frontend/src/api/nginxApi.js @@ -0,0 +1,134 @@ +/** + * Nginx转发管理 API 请求封装 + * + * 所有请求经过 Mokee Gateway(非直连后端),网关根据 X-System-Code 头 + * 自动识别并转发到对应业务后端。路径必须带 /api 前缀以匹配后端路由。 + */ +import request from '../utils/request' + +// ═══════════════════════════════════════════════════════ +// 转发规则管理 +// ═══════════════════════════════════════════════════════ + +export function getRules() { + return request.get('/api/rules') +} + +export function getRule(id) { + return request.get(`/api/rules/${id}`) +} + +/** 新增转发规则(HTTPS LE 签发需 60s+,超时设 5 分钟) */ +export function addRule(data) { + return request.post('/api/rules', data, { timeout: 300000 }) +} + +/** 修改转发规则 */ +export function updateRule(id, data) { + return request.put(`/api/rules/${id}`, data, { timeout: 300000 }) +} + +export function deleteRule(id) { + return request.delete(`/api/rules/${id}`) +} + +export function reloadNginx() { + return request.post('/api/rules/reload') +} + +export function getNginxStatus() { + return request.get('/api/rules/status') +} + +/** 获取指定规则对应的 Nginx 配置文件原始内容 */ +export function getRuleConfig(id) { + return request.get(`/api/rules/${id}/config`) +} + +/** 直接更新 Nginx 配置文件 */ +export function updateRuleConfig(id, content) { + return request.put(`/api/rules/${id}/config`, { content }) +} + +/** 获取 SSL 证书和密钥文件内容 */ +export function getRuleSslFiles(id) { + return request.get(`/api/rules/${id}/ssl`) +} + +// ═══════════════════════════════════════════════════════ +// 内容压缩(gzip) +// ═══════════════════════════════════════════════════════ + +/** 获取规则的 gzip 压缩配置 */ +export function getRuleGzip(id) { + return request.get(`/api/rules/${id}/gzip`) +} + +/** 更新规则的 gzip 压缩配置 */ +export function updateRuleGzip(id, data) { + return request.put(`/api/rules/${id}/gzip`, data) +} + +// ═══════════════════════════════════════════════════════ +// 日志查看 +// ═══════════════════════════════════════════════════════ + +/** 获取域名的 Nginx 响应日志和错误日志 */ +export function getRuleLogs(id, lines = 200) { + return request.get(`/api/rules/${id}/logs`, { params: { lines } }) +} + +// ═══════════════════════════════════════════════════════ +// 证书管理 +// ═══════════════════════════════════════════════════════ + +export function getCertificates() { + return request.get('/api/certificates') +} + +export function getCertificate(id) { + return request.get(`/api/certificates/${id}`) +} + +/** 签发证书(LE 签发需 2min+,超时设 5 分钟) */ +export function issueCertificate(id) { + return request.post(`/api/certificates/${id}/issue`, null, { timeout: 300000 }) +} + +/** 续期证书 */ +export function renewCertificate(id) { + return request.post(`/api/certificates/${id}/renew`, null, { timeout: 300000 }) +} + +/** 更新通知配置 */ +export function updateCertNotifyConfig(id, data) { + return request.put(`/api/certificates/${id}/notify-config`, data) +} + +// ═══════════════════════════════════════════════════════ +// DNS 解析管理 +// ═══════════════════════════════════════════════════════ + +export function getDnsZones() { + return request.get('/api/dns/zones') +} + +export function getDnsDefaultZone() { + return request.get('/api/dns/default-zone') +} + +export function getDnsRecords(zone = 'zgitm.com') { + return request.get('/api/dns/records', { params: { zone } }) +} + +export function addDnsRecord(data) { + return request.post('/api/dns/records', data) +} + +export function deleteDnsRecord(recordId) { + return request.delete(`/api/dns/records/${recordId}`) +} + +export function updateDnsRecord(recordId, data) { + return request.put(`/api/dns/records/${recordId}`, data) +} diff --git a/nginx-manager-frontend/src/assets/hero.png b/nginx-manager-frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/nginx-manager-frontend/src/assets/hero.png differ diff --git a/nginx-manager-frontend/src/assets/vite.svg b/nginx-manager-frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/nginx-manager-frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/nginx-manager-frontend/src/composables/useMobile.js b/nginx-manager-frontend/src/composables/useMobile.js new file mode 100644 index 0000000..87d8ee7 --- /dev/null +++ b/nginx-manager-frontend/src/composables/useMobile.js @@ -0,0 +1,27 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +/** + * 移动端检测 composable + * 统一管理响应式断点判断,避免各组件重复手写 + * + * @param {number} breakpoint - 断点宽度,默认 768px + * @returns {{ isMobile: import('vue').Ref }} + */ +export function useMobile(breakpoint = 768) { + const isMobile = ref(false) + + function check() { + isMobile.value = window.innerWidth <= breakpoint + } + + onMounted(() => { + check() + window.addEventListener('resize', check) + }) + + onUnmounted(() => { + window.removeEventListener('resize', check) + }) + + return { isMobile } +} diff --git a/nginx-manager-frontend/src/layout/MainLayout.vue b/nginx-manager-frontend/src/layout/MainLayout.vue new file mode 100644 index 0000000..98d2989 --- /dev/null +++ b/nginx-manager-frontend/src/layout/MainLayout.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/nginx-manager-frontend/src/main.js b/nginx-manager-frontend/src/main.js new file mode 100644 index 0000000..ccbd136 --- /dev/null +++ b/nginx-manager-frontend/src/main.js @@ -0,0 +1,68 @@ +/** + * Nginx域名转发管理平台 — 启动入口 + */ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router, { addDynamicRoutes } from './router' +import request, { getToken } from './utils/request' + +const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE +const LOGIN_URL = import.meta.env.VITE_LOGIN_URL + +async function bootstrap() { + // 1. 检查 Token + const token = getToken() + if (!token) { + window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}` + return + } + + // 2. 拉取网关动态菜单 + try { + const res = await request.get(`/zgapi/v1/auth/menu/router?systemCode=${SYSTEM_CODE}`) + const raw = res.data + // 兼容新旧两种返回格式: + // 旧:data 直接是数组 [{...}, {...}] + // 新:data 是对象 { menu: [...], permission: [...] } + let menuData + if (Array.isArray(raw)) { + menuData = raw + } else if (raw && Array.isArray(raw.menu)) { + menuData = raw.menu + } else { + throw new Error('菜单数据格式错误') + } + addDynamicRoutes(menuData) + } catch (e) { + console.error('[Bootstrap] 获取网关菜单失败', e) + document.body.innerHTML = ` +
+
+

⚠️ 菜单加载失败

+

无法从网关获取系统菜单,请稍后重试。

+

${e.message}

+
+
` + return + } + + // 3. 挂载 Vue 应用 + const app = createApp(App) + app.use(ElementPlus) + app.use(router) + app.mount('#app') + + console.log('[Bootstrap] 应用启动完成') + + // 4. Vue 渲染完成后,动态加载 Widget 脚本 + // - 加 process polyfill(Widget 内部引用了 process.env) + // - 必须在 mount 之后加载,否则 Widget 找不到 #mokee-user-chip 元素 + window.process = { env: {} } + const script = document.createElement('script') + script.src = 'https://web.gw.zgitm.com/widgets/user-chip.js' + document.body.appendChild(script) +} + +bootstrap() diff --git a/nginx-manager-frontend/src/router/index.js b/nginx-manager-frontend/src/router/index.js new file mode 100644 index 0000000..5032474 --- /dev/null +++ b/nginx-manager-frontend/src/router/index.js @@ -0,0 +1,96 @@ +/** + * 路由配置 — 网关统一登录模式 + * + * - beforeEach 守卫检查 Cookie token,无则跳登录页 + * - 菜单从网关 /zgapi/v1/auth/menu/router 动态拉取 + * - 页面路由全部扁平注册到 /,侧边栏层级由 menuTree 单独渲染 + */ +import { createRouter, createWebHashHistory } from 'vue-router' +import MainLayout from '../layout/MainLayout.vue' +import { getToken } from '../utils/request' + +const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE +const LOGIN_URL = import.meta.env.VITE_LOGIN_URL + +// component 字符串 → 实际 Vue 组件映射 +const COMPONENT_MAP = { + 'views/NginxForwarding.vue': () => import('../views/NginxForwarding.vue'), + 'views/CertManagement.vue': () => import('../views/CertManagement.vue'), + 'views/DnsManagement.vue': () => import('../views/DnsManagement.vue'), +} + +/** 网关返回的原始菜单树(供 MainLayout 渲染侧边栏) */ +export let menuTree = [] + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: '/', + component: MainLayout, + redirect: '/nginx-forwarding', + children: [] // 动态注入 + } + ] +}) + +// 路由守卫 +router.beforeEach((_to, _from, next) => { + const token = getToken() + if (!token) { + window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}` + return + } + next() +}) + +/** + * 根据网关菜单树动态注册路由(全部扁平注册到 / 下) + */ +export function addDynamicRoutes(tree) { + if (!Array.isArray(tree)) return + menuTree = tree + + function walk(menus) { + const routes = [] + for (const m of menus) { + if (m.component === 'Layout' && Array.isArray(m.children) && m.children.length > 0) { + // 目录菜单:递归处理子菜单,不嵌套路由 + routes.push(...walk(m.children)) + } else if (m.component && m.component !== 'Layout') { + // 页面菜单:直接添加到根路由 + const comp = COMPONENT_MAP[m.component] + if (comp) { + routes.push({ + path: m.path, + name: m.name, + component: comp, + meta: { title: m.name, icon: m.meta?.icon || m.icon } + }) + console.log(`[Router] 注册路由: ${m.path}`) + } else { + console.warn(`[Router] 未找到组件映射: ${m.component}`) + } + } + } + return routes + } + + const flatRoutes = walk(tree) + for (const route of flatRoutes) { + router.addRoute('/', route) + } + + // 更新默认重定向为第一个页面 + if (flatRoutes.length > 0) { + router.removeRoute('/') + router.addRoute({ + path: '/', + component: MainLayout, + redirect: flatRoutes[0].path, + children: flatRoutes + }) + } +} + +export default router diff --git a/nginx-manager-frontend/src/utils/request.js b/nginx-manager-frontend/src/utils/request.js new file mode 100644 index 0000000..0cb44ed --- /dev/null +++ b/nginx-manager-frontend/src/utils/request.js @@ -0,0 +1,61 @@ +/** + * 网关统一登录 — Axios 请求实例 + * + * baseURL → 网关地址(非业务后端) + * 网关根据 X-System-Code 头自动识别并转发到对应业务后端 + */ +import axios from 'axios' + +const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE +const LOGIN_URL = import.meta.env.VITE_LOGIN_URL + +/** + * 从 Cookie 读取 Token(网关登录后写入 .zgitm.com 跨域 Cookie) + */ +export function getToken() { + const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/) + return match ? decodeURIComponent(match[1]) : null +} + +const request = axios.create({ + baseURL: import.meta.env.VITE_GATEWAY_URL, + timeout: 30000, +}) + +// ════════════════════════════════════════════ +// 请求拦截器:携带 Token + 系统编码 +// ════════════════════════════════════════════ +request.interceptors.request.use(config => { + const token = getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + // 关键:网关根据此头识别业务系统并转发 + config.headers['X-System-Code'] = SYSTEM_CODE + return config +}) + +// ════════════════════════════════════════════ +// 响应拦截器 +// ════════════════════════════════════════════ +request.interceptors.response.use( + response => { + const data = response.data + // 网关自身 API 返回 code:200,业务后端返回 code:0,都算成功 + if (data.code === 200 || data.code === 0) { + return data + } + return Promise.reject(data) + }, + error => { + // 401 → Token 过期或无效 → 跳转统一登录页 + if (error.response?.status === 401) { + window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}` + } + // 提取后端返回的实际错误消息,而非 axios 默认的 "Request failed with status code xxx" + const message = error.response?.data?.message || error.message || '网络错误' + return Promise.reject(new Error(message)) + } +) + +export default request diff --git a/nginx-manager-frontend/src/views/CertManagement.vue b/nginx-manager-frontend/src/views/CertManagement.vue new file mode 100644 index 0000000..250a787 --- /dev/null +++ b/nginx-manager-frontend/src/views/CertManagement.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/nginx-manager-frontend/src/views/DnsManagement.vue b/nginx-manager-frontend/src/views/DnsManagement.vue new file mode 100644 index 0000000..a49ac1d --- /dev/null +++ b/nginx-manager-frontend/src/views/DnsManagement.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/nginx-manager-frontend/src/views/NginxForwarding.vue b/nginx-manager-frontend/src/views/NginxForwarding.vue new file mode 100644 index 0000000..9863c28 --- /dev/null +++ b/nginx-manager-frontend/src/views/NginxForwarding.vue @@ -0,0 +1,400 @@ + + + + + diff --git a/nginx-manager-frontend/vite.config.js b/nginx-manager-frontend/vite.config.js new file mode 100644 index 0000000..4186627 --- /dev/null +++ b/nginx-manager-frontend/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 31002 + } +}) diff --git a/nginx-python-api/app.py b/nginx-python-api/app.py new file mode 100644 index 0000000..ff99047 --- /dev/null +++ b/nginx-python-api/app.py @@ -0,0 +1,619 @@ +""" +Nginx域名转发管理 - Python API 主入口 +部署在 Nginx 服务器 (10.1.1.160:5000) 上 +直接操作本机 Nginx 配置和证书 +""" +import os +import logging +from flask import Flask, request, g +from services.nginx_service import NginxService +from services.ssl_service import SSLService +from services.dns_service import DnsService + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +app.config['JSON_AS_ASCII'] = False # 中文不转 Unicode + +# ════════════════════════════════════════════ +# 网关用户信息中间件 +# ════════════════════════════════════════════ +USER_HEADERS = [ + 'X-User-Id', 'X-User-Name', 'X-User-Account', + 'X-User-Email', 'X-User-Post', 'X-User-Roles', + 'X-System-Id', 'X-System-Code', 'X-System-Name', +] + + +@app.before_request +def user_context_middleware(): + """从网关注入的请求头提取用户信息,存到 Flask g 对象""" + for h in USER_HEADERS: + val = request.headers.get(h) + if val: + setattr(g, h.replace('-', '_').lower(), val) + +# 初始化服务 +nginx_conf_dir = os.environ.get('NGINX_CONF_DIR', '/etc/nginx/conf.d') +nginx_ssl_dir = os.environ.get('NGINX_SSL_DIR', '/etc/nginx/ssl') +ca_dir = os.environ.get('CA_DIR', '/etc/nginx/ssl/ca') + +nginx_service = NginxService(nginx_conf_dir, nginx_ssl_dir) +ssl_service = SSLService(ca_dir, nginx_ssl_dir) +dns_service = DnsService() + +# 启动时初始化内部 CA(gunicorn 模式下也必须执行) +ssl_service.init_ca() + +# ────────────────────────────────────────────── +# API 路由 +# ────────────────────────────────────────────── + + +@app.route('/api/nginx/health', methods=['GET']) +def health_check(): + """健康检查""" + return { + 'code': 0, + 'message': 'Nginx Manager API is running', + 'data': { + 'nginx_installed': os.path.exists('/usr/sbin/nginx') or os.path.exists('/usr/bin/nginx'), + 'conf_dir': nginx_conf_dir, + 'ssl_dir': nginx_ssl_dir + } + } + + +@app.route('/api/nginx/rules', methods=['GET']) +def list_rules(): + """获取所有转发规则列表""" + try: + rules = nginx_service.list_rules() + return {'code': 0, 'message': 'success', 'data': rules} + except Exception as e: + logger.error(f"获取规则列表失败: {e}") + return {'code': -1, 'message': str(e), 'data': []} + + +@app.route('/api/nginx/rules', methods=['POST']) +def add_rule(): + """新增转发规则""" + try: + body = request.get_json(force=True) + domain = body.get('domain', '').strip() + protocol = body.get('protocol', '').strip().lower() + target = body.get('target', '').strip() + + # 参数校验 + if not domain: + return {'code': -1, 'message': '域名不能为空'}, 400 + if protocol not in ('http', 'https'): + return {'code': -1, 'message': '协议类型必须为 http 或 https'}, 400 + if not target.startswith('http://') and not target.startswith('https://'): + return {'code': -1, 'message': '转发目标必须以 http:// 或 https:// 开头'}, 400 + + # 系统保护域名不允许手动创建(zgitm.com / www.zgitm.com) + if ssl_service.is_system_domain(domain): + return {'code': -1, 'message': f'{domain} 是系统保护域名,不能手动创建'}, 400 + + logger.info(f"新增规则: domain={domain}, protocol={protocol}, target={target}") + + # 检查是否已存在 + existing = nginx_service.find_rule(domain) + if existing: + return {'code': -1, 'message': f'域名 {domain} 的规则已存在'}, 409 + + # HTTPS证书签发 + ssl_cert = None + ssl_key = None + if protocol == 'https': + if ssl_service.is_zgitm_domain(domain): + # zgitm.com 子域名 → Let's Encrypt DNS-01 + ssl_cert, ssl_key = ssl_service.ensure_le_certificate(domain) + if not ssl_cert or not ssl_key: + return {'code': -1, 'message': f'{domain} Let\'s Encrypt 证书签发失败,请查看服务器日志'}, 500 + else: + # 其他域名 → 内部 CA + ssl_cert, ssl_key = ssl_service.ensure_certificate(domain) + + # 创建 Nginx 配置文件 + config_file = nginx_service.create_config(domain, protocol, target, ssl_cert, ssl_key) + + # 检查 Nginx 配置语法 + valid, error_msg = nginx_service.check_config() + if not valid: + # 回滚:删除刚创建的配置文件 + nginx_service.delete_config(domain) + logger.error(f"Nginx 配置语法错误: {error_msg}") + return {'code': -1, 'message': f'Nginx 配置语法检查失败: {error_msg}'}, 400 + + return { + 'code': 0, + 'message': '规则创建成功', + 'data': { + 'domain': domain, + 'protocol': protocol, + 'target': target, + 'config_file': config_file, + 'ssl_cert': ssl_cert, + 'ssl_key': ssl_key + } + } + except Exception as e: + logger.error(f"新增规则失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/rules/', methods=['GET']) +def get_rule(domain): + """获取单个规则详情""" + try: + rule = nginx_service.find_rule(domain) + if rule: + return {'code': 0, 'message': 'success', 'data': rule} + return {'code': -1, 'message': f'域名 {domain} 的规则不存在'}, 404 + except Exception as e: + logger.error(f"获取规则详情失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/rules/', methods=['PUT']) +def update_rule(domain): + """修改转发规则""" + try: + body = request.get_json(force=True) + new_domain = body.get('domain', '').strip() + new_protocol = body.get('protocol', '').strip().lower() + new_target = body.get('target', '').strip() + + if not new_domain: + return {'code': -1, 'message': '域名不能为空'}, 400 + if new_protocol not in ('http', 'https'): + return {'code': -1, 'message': '协议类型必须为 http 或 https'}, 400 + if not new_target.startswith('http://') and not new_target.startswith('https://'): + return {'code': -1, 'message': '转发目标必须以 http:// 或 https:// 开头'}, 400 + + logger.info(f"修改规则: domain={domain} -> new_domain={new_domain}") + + # 检查原规则是否存在 + old_rule = nginx_service.find_rule(domain) + if not old_rule: + return {'code': -1, 'message': f'域名 {domain} 的规则不存在'}, 404 + + # 删除旧配置 + nginx_service.delete_config(domain) + + # HTTPS证书签发 + ssl_cert = None + ssl_key = None + if new_protocol == 'https': + if ssl_service.is_zgitm_domain(new_domain): + ssl_cert, ssl_key = ssl_service.ensure_le_certificate(new_domain) + if not ssl_cert or not ssl_key: + return {'code': -1, 'message': f'{new_domain} Let\'s Encrypt 证书签发失败'}, 500 + else: + ssl_cert, ssl_key = ssl_service.ensure_certificate(new_domain) + + # 创建新配置 + config_file = nginx_service.create_config(new_domain, new_protocol, new_target, ssl_cert, ssl_key) + + # 语法检查 + valid, error_msg = nginx_service.check_config() + if not valid: + nginx_service.delete_config(new_domain) + # 恢复旧配置 + old_proto = old_rule.get('protocol', 'http') + old_cert = old_rule.get('ssl_cert') + old_key = old_rule.get('ssl_key') + nginx_service.create_config(domain, old_proto, old_rule.get('target', ''), old_cert, old_key) + logger.error(f"Nginx 配置语法错误: {error_msg}") + return {'code': -1, 'message': f'Nginx 配置语法检查失败: {error_msg}'}, 400 + + return { + 'code': 0, + 'message': '规则修改成功', + 'data': { + 'domain': new_domain, + 'protocol': new_protocol, + 'target': new_target, + 'config_file': config_file, + 'ssl_cert': ssl_cert, + 'ssl_key': ssl_key + } + } + except Exception as e: + logger.error(f"修改规则失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/rules/', methods=['DELETE']) +def delete_rule(domain): + """删除转发规则""" + try: + logger.info(f"删除规则: domain={domain}") + + rule = nginx_service.find_rule(domain) + + # 删除配置文件(如果存在) + deleted = nginx_service.delete_config(domain) + + # 文件不存在但数据库有记录 → 允许继续(清理脏数据) + if not rule and not deleted: + logger.info(f"域名 {domain} 的规则文件不存在,跳过删除") + + # 检查语法(删除后) + valid, error_msg = nginx_service.check_config() + if not valid: + logger.warning(f"删除后 Nginx 配置语法错误: {error_msg}") + + return {'code': 0, 'message': '规则已删除', 'data': {'domain': domain}} + except Exception as e: + logger.error(f"删除规则失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +# ────────────────────────────────────────────── +# 配置文件查看/编辑接口 +# ────────────────────────────────────────────── + + +@app.route('/api/nginx/rules//config', methods=['GET']) +def get_config(domain): + """获取指定域名的 Nginx 配置文件原始内容""" + try: + import os as _os + config_path = _os.path.join(nginx_conf_dir, f'{domain}.conf') + if not _os.path.exists(config_path): + return {'code': -1, 'message': f'{domain} 的配置文件不存在', 'data': None}, 404 + + with open(config_path, 'r', encoding='utf-8') as f: + content = f.read() + + return { + 'code': 0, + 'message': 'success', + 'data': { + 'domain': domain, + 'config_file': config_path, + 'content': content + } + } + except Exception as e: + logger.error(f"获取配置文件失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/rules//config', methods=['PUT']) +def update_config(domain): + """直接写入 Nginx 配置文件(会进行语法检查,失败自动回滚)""" + try: + body = request.get_json(force=True) + new_content = body.get('content', '') + + if not new_content.strip(): + return {'code': -1, 'message': '配置内容不能为空'}, 400 + + import os as _os + config_path = _os.path.join(nginx_conf_dir, f'{domain}.conf') + old_content = None + old_exists = _os.path.exists(config_path) + + # 备份旧内容(如果存在) + if old_exists: + with open(config_path, 'r', encoding='utf-8') as f: + old_content = f.read() + + # 写入新内容 + with open(config_path, 'w', encoding='utf-8') as f: + f.write(new_content) + logger.info(f"已写入配置文件: {config_path}") + + # 语法检查 + valid, error_msg = nginx_service.check_config() + if not valid: + # 回滚 + if old_exists: + with open(config_path, 'w', encoding='utf-8') as f: + f.write(old_content) + else: + _os.remove(config_path) + logger.error(f"Nginx 配置语法错误,已回滚: {error_msg}") + return {'code': -1, 'message': f'配置语法检查失败,已自动回滚: {error_msg}'}, 400 + + # 重载 Nginx + nginx_service.reload() + logger.info(f"Nginx 配置已重载: {domain}") + + return { + 'code': 0, + 'message': '配置已保存并重载生效', + 'data': {'domain': domain, 'config_file': config_path} + } + except Exception as e: + logger.error(f"更新配置文件失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/rules//ssl', methods=['GET']) +def get_ssl_files(domain): + """获取指定域名的 SSL 证书和密钥文件内容(仅查看,不可编辑)""" + try: + import os as _os + cert_path = _os.path.join(nginx_ssl_dir, f'{domain}.pem') + key_path = _os.path.join(nginx_ssl_dir, f'{domain}.key') + + cert_content = None + key_content = None + + if _os.path.exists(cert_path): + with open(cert_path, 'r', encoding='utf-8') as f: + cert_content = f.read() + if _os.path.exists(key_path): + with open(key_path, 'r', encoding='utf-8') as f: + key_content = f.read() + + if not cert_content and not key_content: + return {'code': -1, 'message': f'{domain} 没有 SSL 证书文件', 'data': None}, 404 + + return { + 'code': 0, + 'message': 'success', + 'data': { + 'domain': domain, + 'cert_path': cert_path, + 'cert_content': cert_content, + 'key_path': key_path, + 'key_content': key_content, + 'has_cert': bool(cert_content), + 'has_key': bool(key_content) + } + } + except Exception as e: + logger.error(f"获取SSL证书文件失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +# ────────────────────────────────────────────── +# 内容压缩(gzip)接口 +# ────────────────────────────────────────────── + + +@app.route('/api/nginx/rules//gzip', methods=['GET']) +def get_gzip_config(domain): + """获取指定域名的 gzip 压缩配置""" + try: + gzip_config = nginx_service.parse_gzip_config(domain) + return {'code': 0, 'message': 'success', 'data': gzip_config} + except Exception as e: + logger.error(f"获取gzip配置失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/rules//gzip', methods=['PUT']) +def update_gzip_config(domain): + """更新指定域名的 gzip 压缩配置""" + try: + body = request.get_json(force=True) + enabled = body.get('enabled', False) + types = body.get('types', []) + comp_level = body.get('comp_level', 6) + min_length = body.get('min_length', '1000') + + # 参数校验 + if enabled and not isinstance(types, list): + return {'code': -1, 'message': 'types 必须是数组'}, 400 + if enabled and comp_level not in range(1, 10): + return {'code': -1, 'message': '压缩级别必须在 1-9 之间'}, 400 + if enabled and not min_length: + return {'code': -1, 'message': '最小压缩长度不能为空'}, 400 + + gzip_config = { + 'enabled': enabled, + 'types': types, + 'comp_level': comp_level, + 'min_length': min_length + } + + success, message = nginx_service.set_gzip_config(domain, gzip_config) + if success: + return {'code': 0, 'message': message, 'data': gzip_config} + return {'code': -1, 'message': message}, 400 + except Exception as e: + logger.error(f"更新gzip配置失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +# ────────────────────────────────────────────── +# 域名日志接口 +# ────────────────────────────────────────────── + + +@app.route('/api/nginx/rules//logs', methods=['GET']) +def get_domain_logs(domain): + """获取指定域名的 Nginx 访问日志和错误日志""" + try: + lines = request.args.get('lines', 200, type=int) + # 限制最大行数 + lines = min(lines, 1000) + logs = nginx_service.get_domain_logs(domain, lines=lines) + return {'code': 0, 'message': 'success', 'data': logs} + except Exception as e: + logger.error(f"获取域名日志失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/reload', methods=['POST']) +def reload_nginx(): + """重载 Nginx 配置""" + try: + logger.info("重载 Nginx 配置") + success, message = nginx_service.reload() + if success: + return {'code': 0, 'message': message} + return {'code': -1, 'message': message}, 500 + except Exception as e: + logger.error(f"重载 Nginx 失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +# ────────────────────────────────────────────── +# 证书管理接口(Let's Encrypt) +# ────────────────────────────────────────────── + +@app.route('/api/nginx/certs', methods=['GET']) +def list_certs(): + """获取所有 SSL 证书信息列表(Let's Encrypt + 内部 CA)""" + try: + certs = [] + # 扫描 SSL 目录中所有的 .pem 证书文件 + import glob as _glob + # 要排除的文件(CA 根证书等) + exclude_files = {'ca.pem', 'ca.crt'} + for cert_file in _glob.glob(f'{nginx_ssl_dir}/*.pem'): + filename = os.path.basename(cert_file) + if filename in exclude_files: + continue + domain = filename.replace('.pem', '') + info = ssl_service.get_le_cert_info(domain) + certs.append(info) + return {'code': 0, 'message': 'success', 'data': certs} + except Exception as e: + logger.error(f"获取证书列表失败: {e}") + return {'code': -1, 'message': str(e), 'data': []} + + +@app.route('/api/nginx/certs/', methods=['GET']) +def get_cert_info(domain): + """获取单个 SSL 证书详情(Let's Encrypt + 内部 CA)""" + try: + info = ssl_service.get_le_cert_info(domain) + return {'code': 0, 'message': 'success', 'data': info} + except Exception as e: + logger.error(f"获取证书信息失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/certs//issue', methods=['POST']) +def issue_cert(domain): + """首次签发或重新签发 Let's Encrypt 证书""" + try: + if not ssl_service.is_zgitm_domain(domain): + return {'code': -1, 'message': f'{domain} 不是 zgitm.com 域名'}, 400 + + logger.info(f"签发 LE 证书: {domain}") + cert_path, key_path = ssl_service.ensure_le_certificate(domain) + + if cert_path and key_path: + # 获取最新证书信息 + info = ssl_service.get_le_cert_info(domain) + return {'code': 0, 'message': f'{domain} 证书签发成功', 'data': info} + else: + return {'code': -1, 'message': f'{domain} 证书签发失败,请检查阿里云 AccessKey 和网络'}, 500 + except Exception as e: + logger.error(f"签发证书失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/certs//renew', methods=['POST']) +def renew_cert(domain): + """手动续期 Let's Encrypt 证书""" + try: + if not ssl_service.is_zgitm_domain(domain): + return {'code': -1, 'message': f'{domain} 不是 zgitm.com 域名'}, 400 + + logger.info(f"手动续期 LE 证书: {domain}") + result = ssl_service.renew_le_certificate(domain) + + if result['success']: + return {'code': 0, 'message': result['message'], 'data': {'log': result['log']}} + else: + return {'code': -1, 'message': result['message'], 'data': {'log': result['log']}}, 500 + except Exception as e: + logger.error(f"续期证书失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +# ────────────────────────────────────────────── +# DNS 解析管理接口 +# ────────────────────────────────────────────── + +@app.route('/api/nginx/dns/records', methods=['GET']) +def list_dns_records(): + """获取 DNS 解析记录列表""" + try: + zone = request.args.get('zone', ssl_service.get_le_root_domain()) + rr = request.args.get('rr', None) + rtype = request.args.get('type', None) + records = dns_service.list_records(zone, rr_keyword=rr, record_type=rtype) + return {'code': 0, 'message': 'success', 'data': records} + except Exception as e: + logger.error(f"获取DNS记录失败: {e}") + return {'code': -1, 'message': str(e), 'data': []} + + +@app.route('/api/nginx/dns/records', methods=['POST']) +def add_dns_record(): + """添加 DNS 解析记录""" + try: + body = request.get_json(force=True) + zone = body.get('zone', ssl_service.get_le_root_domain()) + rr = body.get('rr', '').strip() + record_type = body.get('type', 'A').upper() + value = body.get('value', '').strip() + ttl = body.get('ttl', 600) + + if not rr: + return {'code': -1, 'message': '主机记录(rr)不能为空'}, 400 + if not value: + return {'code': -1, 'message': '记录值(value)不能为空'}, 400 + + result = dns_service.add_record(zone, rr, record_type, value, ttl) + return {'code': 0, 'message': f'{result["full_domain"]} 记录已添加', 'data': result} + except Exception as e: + logger.error(f"添加DNS记录失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/dns/records/', methods=['DELETE']) +def delete_dns_record(record_id): + """删除 DNS 解析记录""" + try: + dns_service.delete_record(record_id) + return {'code': 0, 'message': 'DNS记录已删除'} + except Exception as e: + logger.error(f"删除DNS记录失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +@app.route('/api/nginx/dns/records/', methods=['PUT']) +def update_dns_record(record_id): + """修改 DNS 解析记录""" + try: + body = request.get_json(force=True) + rr = body.get('rr', '@') + record_type = body.get('type', 'A').upper() + value = body.get('value', '').strip() + ttl = body.get('ttl', 600) + + if not value: + return {'code': -1, 'message': '记录值(value)不能为空'}, 400 + + dns_service.update_record(record_id, rr, record_type, value, ttl) + return {'code': 0, 'message': 'DNS记录已更新'} + except Exception as e: + logger.error(f"修改DNS记录失败: {e}") + return {'code': -1, 'message': str(e)}, 500 + + +# ────────────────────────────────────────────── +# 启动入口 +# ────────────────────────────────────────────── + +if __name__ == '__main__': + logger.info("Nginx Manager API 启动在端口 5000") + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/nginx-python-api/deploy.sh b/nginx-python-api/deploy.sh new file mode 100644 index 0000000..4ef4249 --- /dev/null +++ b/nginx-python-api/deploy.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# ============================================== +# Nginx Manager Python API 部署脚本 +# 目标服务器: 10.1.1.160 (Rocky Linux 8.10) +# ============================================== +set -e + +echo "=========================================" +echo "Nginx Manager Python API 部署脚本" +echo "=========================================" + +# 1. 安装系统依赖 +echo "[1/5] 安装系统依赖..." +dnf install -y epel-release +dnf install -y nginx python3 python3-pip openssl + +# 2. 启动 Nginx(不配置任何站点) +echo "[2/5] 配置 Nginx..." +systemctl enable nginx +systemctl start nginx + +# 3. 创建项目目录 +echo "[3/5] 创建项目目录..." +mkdir -p /opt/nginx-api/services +mkdir -p /etc/nginx/conf.d +mkdir -p /etc/nginx/ssl/ca + +# 4. 安装 Python 依赖 +echo "[4/5] 安装 Python 依赖..." +pip3 install -r /opt/nginx-api/requirements.txt + +# 5. 创建 systemd 服务 +echo "[5/5] 配置 systemd 服务..." +cat > /etc/systemd/system/nginx-api.service << 'SYSTEMD_EOF' +[Unit] +Description=Nginx Manager Python API +After=network.target nginx.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/nginx-api +ExecStart=/usr/bin/python3 -m gunicorn -c gunicorn_config.py app:app +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +SYSTEMD_EOF + +# 6. 修复 SELinux 阻止 Nginx 出站连接(避免 502) +echo "配置 SELinux..." +setsebool -P httpd_can_network_connect 1 2>/dev/null || true + +# 7. 开放防火墙端口 +echo "配置防火墙..." +firewall-cmd --permanent --add-port=5000/tcp 2>/dev/null || true +firewall-cmd --reload 2>/dev/null || true + +# 8. 启动服务 +systemctl daemon-reload +systemctl enable nginx-api +systemctl start nginx-api + +echo "" +echo "=========================================" +echo "部署完成!" +echo "API 地址: http://10.1.1.160:5000" +echo "健康检查: curl http://10.1.1.160:5000/api/nginx/health" +echo "=========================================" diff --git a/nginx-python-api/gunicorn_config.py b/nginx-python-api/gunicorn_config.py new file mode 100644 index 0000000..46687c0 --- /dev/null +++ b/nginx-python-api/gunicorn_config.py @@ -0,0 +1,29 @@ +""" +Gunicorn 生产环境配置文件 +用法: gunicorn -c gunicorn_config.py app:app +""" +import os + +# 监听地址和端口 +bind = "0.0.0.0:5000" + +# Worker 进程数 +workers = int(os.environ.get('GUNICORN_WORKERS', 2)) + +# Worker 类型 +worker_class = "sync" + +# 超时 +timeout = 60 +graceful_timeout = 10 + +# 日志 +accesslog = "-" # 标准输出 +errorlog = "-" # 标准错误 +loglevel = "info" + +# 进程名称 +proc_name = "nginx-manager-api" + +# 后台运行 +daemon = False diff --git a/nginx-python-api/requirements.txt b/nginx-python-api/requirements.txt new file mode 100644 index 0000000..398de43 --- /dev/null +++ b/nginx-python-api/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.0 +gunicorn==23.0.0 diff --git a/nginx-python-api/services/__init__.py b/nginx-python-api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nginx-python-api/services/cert_notifier.py b/nginx-python-api/services/cert_notifier.py new file mode 100644 index 0000000..bad6049 --- /dev/null +++ b/nginx-python-api/services/cert_notifier.py @@ -0,0 +1,263 @@ +""" +证书到期邮件通知服务 +- 由 cron 每日 10:00 触发 +- 检查数据库中的 Let's Encrypt 证书 +- 到期前 N 天起,连续每天发送提醒邮件 +- 邮件通过 Mokee Gateway 对外开放 API 发送 + +使用方式: + python cert_notifier.py # 直接运行(cron 调用) + python cert_notifier.py --dry-run # 预览模式,不实际发送 + python cert_notifier.py --check # 仅显示证书状态 +""" +import os +import sys +import json +import logging +import urllib.request +import urllib.error +from datetime import datetime + +# 数据库配置 +DB_HOST = os.environ.get('MYSQL_HOST', '10.1.1.122') +DB_PORT = int(os.environ.get('MYSQL_PORT', '3306')) +DB_USER = os.environ.get('MYSQL_USER', 'nginxserver') +DB_PASS = os.environ.get('MYSQL_PASS', 'mokee.') +DB_NAME = os.environ.get('MYSQL_DB', 'nginxserver') + +# 网关配置(可通过环境变量覆盖) +GATEWAY_URL = os.environ.get('MOKEE_GATEWAY_URL', 'https://gw.server.mokee.com') +SYSTEM_CODE = os.environ.get('MOKEE_SYSTEM_CODE', 'nginxServer') + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s' +) +logger = logging.getLogger(__name__) + + +def get_db_connection(): + """获取数据库连接(延迟导入,避免非数据库场景报错)""" + import pymysql + return pymysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, + password=DB_PASS, database=DB_NAME, + charset='utf8mb4', connect_timeout=5 + ) + + +def get_token(): + """从 Mokee Gateway 获取一次性 Token""" + url = f"{GATEWAY_URL}/zgapi/v1/open/token" + data = json.dumps({"systemCode": SYSTEM_CODE}).encode('utf-8') + + req = urllib.request.Request(url, data=data, method='POST') + req.add_header('Content-Type', 'application/json') + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read().decode('utf-8')) + if result.get('code') == 200: + token = result['data']['token'] + logger.info(f"获取 Token 成功: {SYSTEM_CODE}") + return token + else: + logger.error(f"获取 Token 失败: {result}") + return None + except Exception as e: + logger.error(f"获取 Token 异常: {e}") + return None + + +def send_email(token, to, subject, content): + """ + 通过 Mokee Gateway 发送邮件 + 成功 = Token 被消费,需要重新获取 + """ + url = f"{GATEWAY_URL}/zgapi/v1/open/email/send" + data = json.dumps({ + "to": to, + "subject": subject, + "content": content, + "contentType": "html" + }).encode('utf-8') + + req = urllib.request.Request(url, data=data, method='POST') + req.add_header('Content-Type', 'application/json') + req.add_header('Authorization', f'Bearer {token}') + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode('utf-8')) + return result.get('code') == 200, str(result) + except Exception as e: + return False, str(e) + + +def build_email_html(domain, days_left, expire_date): + """生成 HTML 邮件内容""" + if days_left <= 5: + level = 'danger' + color = '#F56C6C' + elif days_left <= 10: + level = 'warning' + color = '#E6A23C' + else: + level = 'info' + color = '#409EFF' + + return f""" + +
+
+

⚠️ SSL 证书即将到期

+
+
+ + + + + + + + + +
域名{domain}
到期时间{expire_date}
剩余天数{days_left} 天
证书类型Let's Encrypt (阿里云 DNS 验证)
+
+

+ 系统已配置 acme.sh 自动续期,正常情况下会在到期前自动更新证书。
+ 如续期失败,请检查:
+ 1. 阿里云 AccessKey 是否有效
+ 2. 服务器能否访问 Let's Encrypt API
+ 3. DNS TXT 记录是否正常清理 +

+

+ 此邮件由 Nginx 域名管理平台自动发送,连续提醒至证书续期成功。 +

+
+
+""" + + +def check_and_notify(dry_run=False): + """检查证书到期情况并发送通知""" + conn = get_db_connection() + cursor = conn.cursor() + + # 查询需要通知的 Let's Encrypt 证书 + cursor.execute(''' + SELECT id, domain, notify_email, notify_enabled, notify_days_before, expire_date + FROM certificates + WHERE cert_type = 'letsencrypt' + AND status = 'active' + AND notify_enabled = 1 + AND notify_email IS NOT NULL + AND notify_email != '' + ''') + + certs = cursor.fetchall() + if not certs: + logger.info("无需通知的 Let's Encrypt 证书") + conn.close() + return + + today = datetime.now() + token = None # 延迟获取 + notified_count = 0 + + for cert_id, domain, notify_email, enabled, days_before, expire_date_db in certs: + # 从数据库获取过期日期 + if expire_date_db: + expire_date = expire_date_db if isinstance(expire_date_db, datetime) else datetime.strptime(str(expire_date_db), '%Y-%m-%d %H:%M:%S') + days_left = (expire_date - today).days + else: + logger.warning(f"{domain}: 无过期日期信息,跳过") + continue + + logger.info(f"{domain}: 剩余 {days_left} 天到期 (通知阈值: {days_before}天前开始)") + + # 判断是否需要通知: days_before 天前开始,连续通知到到期前1天 + if days_left > days_before or days_left <= 0: + logger.info(f"{domain}: 不在通知窗口 (days_left={days_left}, threshold={days_before})") + continue + + # 检查今天是否已发送过通知 + cursor.execute(''' + SELECT id FROM cert_notify_log + WHERE cert_id = %s AND DATE(sent_at) = CURDATE() + ''', (cert_id,)) + if cursor.fetchone(): + logger.info(f"{domain}: 今日已通知,跳过") + continue + + # 生成邮件内容 + expire_str = expire_date.strftime('%Y-%m-%d %H:%M') + subject = f"⚠️ SSL证书到期提醒 - {domain} (剩余{days_left}天)" + content = build_email_html(domain, days_left, expire_str) + + if dry_run: + logger.info(f"[DRY-RUN] 将发送邮件: to={notify_email}, subject={subject}") + continue + + # 获取 Token(每次发送前确保有效) + if token is None: + token = get_token() + if token is None: + logger.error(f"无法获取 Token,跳过 {domain}") + continue + + # 发送邮件 + success, resp = send_email(token, notify_email, subject, content) + if success: + # Token 已消费,下次需要重新获取 + token = None + logger.info(f"{domain}: 邮件发送成功 → {notify_email}") + else: + logger.warning(f"{domain}: 邮件发送失败 → {resp}") + # 500 错误不消费 Token,可重试 + + # 记录日志 + cursor.execute(''' + INSERT INTO cert_notify_log (cert_id, domain, days_left, email_to, success, response_msg) + VALUES (%s, %s, %s, %s, %s, %s) + ''', (cert_id, domain, days_left, notify_email, 1 if success else 0, resp[:1000])) + conn.commit() + + if success: + notified_count += 1 + + conn.close() + logger.info(f"通知完成: 发送 {notified_count} 封邮件") + + +def check_status(): + """仅显示证书状态(--check 模式)""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT domain, cert_type, status, expire_date, + DATEDIFF(COALESCE(expire_date, NOW()), NOW()) AS days_left, + notify_enabled, notify_email, notify_days_before + FROM certificates + WHERE cert_type = 'letsencrypt' + ORDER BY days_left + ''') + + print(f"\n{'域名':25s} {'类型':12s} {'状态':8s} {'到期日期':20s} {'剩余天数':>8s} {'通知':>4s} {'收件人'}") + print('-' * 120) + for row in cursor.fetchall(): + domain, cert_type, status, expire_date, days_left, enabled, email, days_before = row + dl = str(days_left) if days_left is not None else 'N/A' + print(f"{domain:25s} {cert_type:12s} {status:8s} {str(expire_date):20s} {dl:>8s} {'是' if enabled else '否':>4s} {email or '-'}") + + conn.close() + + +if __name__ == '__main__': + if '--dry-run' in sys.argv: + print("=== 预览模式 (--dry-run) ===\n") + check_and_notify(dry_run=True) + elif '--check' in sys.argv: + check_status() + else: + check_and_notify() diff --git a/nginx-python-api/services/dns_service.py b/nginx-python-api/services/dns_service.py new file mode 100644 index 0000000..7d86f5d --- /dev/null +++ b/nginx-python-api/services/dns_service.py @@ -0,0 +1,228 @@ +""" +阿里云 DNS 解析管理服务 +通过阿里云 OpenAPI 管理 zgitm.com 的 DNS 解析记录 +使用 HMAC-SHA1 签名,兼容 Python 3.6+ +""" +import os +import hmac +import hashlib +import base64 +import logging +import urllib.request +import urllib.parse +import urllib.error +import json +import time +import uuid + +logger = logging.getLogger(__name__) + +# 阿里云 API 配置 +ALIDNS_ENDPOINT = 'https://alidns.aliyuncs.com' +API_VERSION = '2015-01-09' +SIGNATURE_METHOD = 'HMAC-SHA1' +SIGNATURE_VERSION = '1.0' + + +class DnsService: + """阿里云 DNS 解析管理""" + + def __init__(self): + self.access_key_id = os.environ.get('Ali_Key', '') + self.access_key_secret = os.environ.get('Ali_Secret', '') + + def _sign(self, method, params): + """ + HMAC-SHA1 签名(阿里云 API 签名 v1.0) + """ + # 1. 按参数名排序 + sorted_keys = sorted(params.keys()) + sorted_params = [(k, params[k]) for k in sorted_keys] + + # 2. 构造规范化查询字符串 + canonicalized = urllib.parse.urlencode(sorted_params) + + # 3. 构造签名源字符串 + string_to_sign = method + '&' + urllib.parse.quote('/', safe='') + '&' + urllib.parse.quote(canonicalized, safe='') + + # 4. HMAC-SHA1 签名 + key = (self.access_key_secret + '&').encode('utf-8') + h = hmac.new(key, string_to_sign.encode('utf-8'), hashlib.sha1) + signature = base64.b64encode(h.digest()).decode('utf-8') + + return signature + + def _call(self, action, extra_params=None): + """ + 调用阿里云 OpenAPI + + Args: + action: API 名称 (如 DescribeDomainRecords) + extra_params: 额外参数 + + Returns: + dict: API 响应 + """ + if not self.access_key_id or not self.access_key_secret: + raise RuntimeError('阿里云 AccessKey 未配置') + + # 公共参数 + params = { + 'AccessKeyId': self.access_key_id, + 'Action': action, + 'Format': 'JSON', + 'Version': API_VERSION, + 'SignatureMethod': SIGNATURE_METHOD, + 'SignatureVersion': SIGNATURE_VERSION, + 'SignatureNonce': str(uuid.uuid4()), + 'Timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + } + if extra_params: + params.update(extra_params) + + # 签名 + params['Signature'] = self._sign('GET', params) + + # 构造 URL + url = ALIDNS_ENDPOINT + '/?' + urllib.parse.urlencode(params) + + # 发送请求 + try: + req = urllib.request.Request(url, method='GET') + with urllib.request.urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode('utf-8')) + return result + except urllib.error.HTTPError as e: + body = e.read().decode('utf-8') if e.fp else '' + logger.error(f'DNS API HTTP {e.code}: {body}') + raise RuntimeError(f'DNS API 请求失败: {e.code} {body}') + except Exception as e: + logger.error(f'DNS API 异常: {e}') + raise RuntimeError(f'DNS API 异常: {e}') + + def list_records(self, domain_zone, rr_keyword=None, record_type=None): + """ + 获取域名解析记录列表 + + Args: + domain_zone: 主域名 (如 zgitm.com) + rr_keyword: 主机记录筛选 (如 www) + record_type: 记录类型筛选 (如 A, CNAME) + + Returns: + list[dict]: 解析记录列表 + """ + params = {'DomainName': domain_zone} + if rr_keyword: + params['RRKeyWord'] = rr_keyword + if record_type: + params['Type'] = record_type + + resp = self._call('DescribeDomainRecords', params) + records = resp.get('DomainRecords', {}).get('Record', []) + # 统一转列表 + if isinstance(records, dict): + records = [records] + + result = [] + for r in records: + result.append({ + 'record_id': r.get('RecordId', ''), + 'rr': r.get('RR', ''), + 'type': r.get('Type', 'A'), + 'value': r.get('Value', ''), + 'ttl': r.get('TTL', 600), + 'line': r.get('Line', 'default'), + 'status': r.get('Status', 'ENABLE'), + 'full_domain': (r.get('RR', '') + '.' + domain_zone).strip('.'), + }) + return result + + def add_record(self, domain_zone, rr, record_type, value, ttl=600): + """ + 添加解析记录 + + Args: + domain_zone: 主域名 + rr: 主机记录 (如 webssd, @, www) + record_type: 记录类型 (A, CNAME, TXT 等) + value: 记录值 (IP地址或域名) + ttl: TTL 秒数 + + Returns: + dict: {'record_id': ..., 'full_domain': ...} + """ + params = { + 'DomainName': domain_zone, + 'RR': rr, + 'Type': record_type.upper(), + 'Value': value, + 'TTL': str(ttl), + } + resp = self._call('AddDomainRecord', params) + record_id = resp.get('RecordId', '') + full_domain = (rr + '.' + domain_zone).strip('.').replace('@.', '') + logger.info(f'DNS 记录已添加: {full_domain} {record_type} {value} (ID: {record_id})') + return {'record_id': record_id, 'full_domain': full_domain} + + def delete_record(self, record_id): + """ + 删除解析记录 + + Args: + record_id: 记录ID + + Returns: + bool: 是否成功 + """ + resp = self._call('DeleteDomainRecord', {'RecordId': str(record_id)}) + logger.info(f'DNS 记录已删除: {record_id}') + return True + + def update_record(self, record_id, rr, record_type, value, ttl=600): + """ + 修改解析记录 + + Args: + record_id: 记录ID + rr: 主机记录 + record_type: 记录类型 + value: 记录值 + ttl: TTL + + Returns: + bool: 是否成功 + """ + params = { + 'RecordId': str(record_id), + 'RR': rr, + 'Type': record_type.upper(), + 'Value': value, + 'TTL': str(ttl), + } + try: + self._call('UpdateDomainRecord', params) + logger.info(f'DNS 记录已修改: {record_id} → {rr} {record_type} {value}') + except RuntimeError as e: + msg = str(e) + # 未做任何改动直接保存,阿里云返回 DomainRecordDuplicate,视为成功 + if 'DomainRecordDuplicate' in msg: + logger.info(f'DNS 记录未变更,跳过: {record_id} {rr} {record_type} {value}') + else: + raise + return True + + def set_record_status(self, record_id, status): + """ + 启用/停用解析记录 + + Args: + record_id: 记录ID + status: Enable / Disable + """ + self._call('SetDomainRecordStatus', { + 'RecordId': str(record_id), + 'Status': status.upper(), + }) + logger.info(f'DNS 记录状态已更新: {record_id} → {status}') + return True diff --git a/nginx-python-api/services/nginx_service.py b/nginx-python-api/services/nginx_service.py new file mode 100644 index 0000000..9707b7a --- /dev/null +++ b/nginx-python-api/services/nginx_service.py @@ -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 diff --git a/nginx-python-api/services/ssl_service.py b/nginx-python-api/services/ssl_service.py new file mode 100644 index 0000000..aa67b49 --- /dev/null +++ b/nginx-python-api/services/ssl_service.py @@ -0,0 +1,532 @@ +""" +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 diff --git a/scripts/clean_mokee.py b/scripts/clean_mokee.py new file mode 100644 index 0000000..db76eae --- /dev/null +++ b/scripts/clean_mokee.py @@ -0,0 +1,106 @@ +""" +清理 10.1.1.160 上所有 mokee.com 证书+Nginx配置+数据库记录 +用法: python3 clean_mokee.py +""" +import paramiko +import pymysql +import sys + +HOST = '10.1.1.160' +USER = 'root' +PASS = 'mokee.' + +DB_HOST = '10.1.1.122' +DB_USER = 'nginxserver' +DB_PASS = 'mokee.' +DB_NAME = 'nginxserver' + + +def ssh_cmd(ssh, cmd, desc=''): + """执行远程命令并打印结果""" + print(f"\n{'='*60}") + print(f">>> {desc or cmd}") + print('='*60) + stdin, stdout, stderr = ssh.exec_command(cmd) + out = stdout.read().decode().strip() + err = stderr.read().decode().strip() + if out: + print(out) + if err: + print('[stderr]', err) + return out + + +def main(): + step = sys.argv[1] if len(sys.argv) > 1 else '' + + if step == '': + # 第一步:查看 + print('>>> 第一步:查看 mokee.com 相关文件 <<<') + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(HOST, username=USER, password=PASS, timeout=10) + + ssh_cmd(ssh, 'ls -la /etc/nginx/ssl/*mokee* 2>/dev/null', 'mokee.com 证书文件') + ssh_cmd(ssh, 'ls -la /etc/nginx/conf.d/*mokee* 2>/dev/null', 'mokee.com Nginx配置') + + ssh.close() + + print('\n>>> 确认无误后执行: python3 clean_mokee.py delete') + + elif step == 'delete': + # 第二步:清理 + print('>>> 第二步:清理 mokee.com 证书+Nginx配置+数据库 <<<') + + # --- 服务器端 --- + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(HOST, username=USER, password=PASS, timeout=10) + + # 删证书 + ssh_cmd(ssh, 'rm -fv /etc/nginx/ssl/*mokee*.pem /etc/nginx/ssl/*mokee*.key /etc/nginx/ssl/*mokee*.crt 2>&1', '删除证书文件') + + # 删Nginx配置 + ssh_cmd(ssh, 'rm -fv /etc/nginx/conf.d/*mokee*.conf 2>&1', '删除Nginx配置') + + # 语法检查+重载 + ssh_cmd(ssh, 'nginx -t 2>&1 && nginx -s reload 2>&1', 'Nginx语法检查+重载') + + ssh.close() + print('\n>>> 服务器端清理完成') + + # --- 数据库端 --- + print('\n>>> 清理数据库记录') + conn = pymysql.connect(host=DB_HOST, user=DB_USER, password=DB_PASS, + database=DB_NAME, charset='utf8mb4') + cur = conn.cursor() + + # 先看 + cur.execute("SELECT id, domain, cert_type, status FROM certificates WHERE domain LIKE '%mokee.com'") + certs = cur.fetchall() + print(f'证书记录: {len(certs)} 条') + for r in certs: + print(f' {r}') + + cur.execute("SELECT id, domain, protocol, target FROM forwarding_rules WHERE domain LIKE '%mokee.com'") + rules = cur.fetchall() + print(f'转发规则: {len(rules)} 条') + for r in rules: + print(f' {r}') + + # 删除 + cur.execute("DELETE FROM certificates WHERE domain LIKE '%mokee.com'") + print(f'已删除证书记录: {cur.rowcount} 条') + + cur.execute("DELETE FROM forwarding_rules WHERE domain LIKE '%mokee.com'") + print(f'已删除转发规则: {cur.rowcount} 条') + + conn.commit() + cur.close() + conn.close() + + print('\n>>> 全部清理完成!') + + +if __name__ == '__main__': + main() diff --git a/scripts/enable_cors.sh b/scripts/enable_cors.sh new file mode 100644 index 0000000..8b12de9 --- /dev/null +++ b/scripts/enable_cors.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# 启用 CORS 跨域支持 +# 步骤1: 恢复带 CORS headers 的 nginx.conf +cp /etc/nginx/nginx.conf.bak.cors /etc/nginx/nginx.conf + +# 步骤2: 给静态资源 location 也加 CORS +# (因为 add_header Cache-Control 会覆盖 http 块继承的 CORS headers) +for f in /etc/nginx/conf.d/*.conf; do + if ! grep -q 'Access-Control-Allow-Origin' "$f"; then + sed -i 's/add_header Cache-Control/add_header Access-Control-Allow-Origin * always;\n add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;\n add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;\n add_header Cache-Control/' "$f" + fi +done + +# 步骤3: 验证并重载 +echo "=== nginx -t ===" +nginx -t && systemctl reload nginx && echo "=== DONE ===" + +# 步骤4: 验证 CORS 是否生效 +echo "" +echo "=== 验证 CORS ===" +curl -sI -H "Origin: http://test.com" -k https://gw.server.zgitm.com/ 2>&1 | grep -i access-control +curl -sI -H "Origin: http://test.com" -k https://web.gw.zgitm.com/ 2>&1 | grep -i access-control +echo "=== 完成 ===" diff --git a/scripts/enable_large_upload.sh b/scripts/enable_large_upload.sh new file mode 100644 index 0000000..51dd6aa --- /dev/null +++ b/scripts/enable_large_upload.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# 允许大文件上传(最大 2GB) +# 全局 http 块 + server 块同时设置 + +# 1. nginx.conf http 块加全局默认值 +if ! grep -q 'client_max_body_size' /etc/nginx/nginx.conf; then + sed -i '/gzip_types text\/plain/i\ client_max_body_size 2048m;\n client_body_timeout 300s;' /etc/nginx/nginx.conf + echo "[OK] nginx.conf: client_max_body_size 2048m" +else + echo "[SKIP] nginx.conf already has client_max_body_size" +fi + +# 2. 每个 server 块也加上(覆盖可能的全局设置) +for f in /etc/nginx/conf.d/*.conf; do + if ! grep -q 'client_max_body_size' "$f"; then + sed -i '/server_name/a\ client_max_body_size 2048m;' "$f" + echo "[OK] $(basename $f): added" + else + echo "[SKIP] $(basename $f): already has" + fi +done + +# 3. 更新 Python API 模板(未来新增规则自动带此配置) +TEMPLATE="/opt/nginx-api/services/nginx_service.py" +if [ -f "$TEMPLATE" ] && ! grep -q 'client_max_body_size' "$TEMPLATE"; then + sed -i '/server_name {domain};/a\ client_max_body_size 2048m;' "$TEMPLATE" + systemctl restart nginx-api + echo "[OK] Python template updated + API restarted" +else + echo "[SKIP] Python template already has client_max_body_size" +fi + +# 4. 验证 +echo "" +echo "=== nginx -t ===" +nginx -t && systemctl reload nginx && echo "=== DONE ===" diff --git a/scripts/fix_nginx_conf.sh b/scripts/fix_nginx_conf.sh new file mode 100644 index 0000000..aa222d3 --- /dev/null +++ b/scripts/fix_nginx_conf.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# 修复 nginx conf.d 中残留的 CORS 修改痕迹 +# 1. 删除孤立的 return 204; +# 2. 删除孤立的 } +# 3. 合并多余空行 + +for f in /etc/nginx/conf.d/*.conf; do + python3 -c " +import re +with open('$f', 'r') as fh: + c = fh.read() + +# 删除 Access-Control 相关行(如果有残留) +c = re.sub(r'.*Access-Control-Allow.*\n', '', c) +c = re.sub(r'.*Access-Control-Max.*\n', '', c) + +# 删除孤立的 return 204; +c = re.sub(r'[ \t]*return 204;\n', '', c) + +# 删除 server_name 和 ssl_certificate 之间孤立的 } +c = re.sub(r'(server_name[^;]+;)\n\n }\n( ssl_certificate)', r'\1\n\n\2', c) + +# 合并多余空行 +c = re.sub(r'\n\n\n+', '\n\n', c) + +with open('$f', 'w') as fh: + fh.write(c) +" +done + +echo "=== nginx -t ===" +nginx -t diff --git a/scripts/run_audit_migration.py b/scripts/run_audit_migration.py new file mode 100644 index 0000000..57c796c --- /dev/null +++ b/scripts/run_audit_migration.py @@ -0,0 +1,78 @@ +"""审计字段数据库迁移脚本""" +import pymysql + +conn = pymysql.connect( + host='10.1.1.122', + user='nginxserver', + password='mokee.', + database='nginxserver', + charset='utf8mb4', + autocommit=True +) +cur = conn.cursor() + +tables = ['forwarding_rules', 'dns_zone_config', 'certificates'] +columns = [ + ('created_by', 'VARCHAR(100) DEFAULT NULL', '创建人'), + ('updated_by', 'VARCHAR(100) DEFAULT NULL', '更新人'), +] + +for table in tables: + for col_name, col_def, col_comment in columns: + cur.execute(f"""SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA='nginxserver' AND TABLE_NAME='{table}' AND COLUMN_NAME='{col_name}'""") + if not cur.fetchone(): + cur.execute(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_def} COMMENT '{col_comment}'") + print(f'{table}.{col_name} 已添加') + else: + print(f'{table}.{col_name} 已存在,跳过') + +# 新建 dns_records 表 +cur.execute("""SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA='nginxserver' AND TABLE_NAME='dns_records'""") +if not cur.fetchone(): + cur.execute(""" + CREATE TABLE dns_records ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + record_id VARCHAR(128) NOT NULL COMMENT '阿里云解析记录ID', + domain_zone VARCHAR(255) NOT NULL COMMENT '主域名', + rr VARCHAR(255) NOT NULL COMMENT '主机记录', + record_type VARCHAR(20) NOT NULL COMMENT '记录类型: A/CNAME/TXT', + record_value VARCHAR(500) NOT NULL COMMENT '记录值', + ttl INT DEFAULT 600 COMMENT 'TTL(秒)', + status TINYINT DEFAULT 1 COMMENT '0-已删除, 1-正常', + created_by VARCHAR(100) DEFAULT NULL COMMENT '创建人', + updated_by VARCHAR(100) DEFAULT NULL COMMENT '更新人', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_record_id (record_id), + INDEX idx_domain_zone (domain_zone), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='DNS解析记录本地审计表' + """) + print('dns_records 表已创建') +else: + print('dns_records 表已存在,跳过') + +# 验证 +print() +print('=== 验证结果 ===') +cur.execute("""SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA='nginxserver' + AND TABLE_NAME IN ('forwarding_rules', 'dns_zone_config', 'certificates') + AND COLUMN_NAME IN ('created_by', 'updated_by') + ORDER BY TABLE_NAME, COLUMN_NAME""") +for row in cur.fetchall(): + print(row) + +print() +cur.execute('SHOW CREATE TABLE dns_records') +row = cur.fetchone() +if row: + print('dns_records 表已就绪') + +cur.close() +conn.close() +print('\n数据库迁移完成!') diff --git a/scripts/set_timeout_12h.sh b/scripts/set_timeout_12h.sh new file mode 100644 index 0000000..d4e8b82 --- /dev/null +++ b/scripts/set_timeout_12h.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# 修改所有代理超时为 12 小时 + +# 1. nginx.conf http 块加全局默认 +if ! grep -q 'proxy_connect_timeout 43200s' /etc/nginx/nginx.conf; then + sed -i '/gzip_types text\/plain/i\ proxy_connect_timeout 43200s;\n proxy_send_timeout 43200s;\n proxy_read_timeout 43200s;\n' /etc/nginx/nginx.conf + echo "[OK] nginx.conf: global timeout 43200s" +else + echo "[SKIP] nginx.conf already has 43200s" +fi + +# 2. 修改所有 conf.d 中的超时 +for f in /etc/nginx/conf.d/*.conf; do + sed -i 's/proxy_connect_timeout 60s;/proxy_connect_timeout 43200s;/g' "$f" + sed -i 's/proxy_send_timeout 60s;/proxy_send_timeout 43200s;/g' "$f" + sed -i 's/proxy_read_timeout 60s;/proxy_read_timeout 43200s;/g' "$f" + echo "[OK] $(basename $f)" +done + +# 3. 更新 Python 模板 +TEMPLATE="/opt/nginx-api/services/nginx_service.py" +if [ -f "$TEMPLATE" ]; then + sed -i 's/proxy_connect_timeout 60s;/proxy_connect_timeout 43200s;/g' "$TEMPLATE" + sed -i 's/proxy_send_timeout 60s;/proxy_send_timeout 43200s;/g' "$TEMPLATE" + sed -i 's/proxy_read_timeout 60s;/proxy_read_timeout 43200s;/g' "$TEMPLATE" + systemctl restart nginx-api + echo "[OK] Python template updated + API restarted" +fi + +echo "" +echo "=== nginx -t ===" +nginx -t && systemctl reload nginx && echo "=== DONE ===" diff --git a/sql/audit_migration.sql b/sql/audit_migration.sql new file mode 100644 index 0000000..c5a359e --- /dev/null +++ b/sql/audit_migration.sql @@ -0,0 +1,151 @@ +-- ============================================ +-- 审计字段迁移:创建人/更新人 + DNS记录本地审计表 +-- 数据库: nginxserver @ 10.1.1.122:3306 +-- 日期: 2026-06-19 +-- 执行方式: mysql -h 10.1.1.122 -u nginxserver -p nginxserver < audit_migration.sql +-- ============================================ + +USE nginxserver; + +-- ════════════════════════════════════════════════ +-- 安全添加审计列(检查是否已存在,避免重复执行报错) +-- ════════════════════════════════════════════════ +DROP PROCEDURE IF EXISTS add_audit_columns; + +DELIMITER $$ +CREATE PROCEDURE add_audit_columns() +BEGIN + -- ======================================== + -- forwarding_rules: created_by + -- ======================================== + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME = 'created_by' + ) THEN + ALTER TABLE forwarding_rules + ADD COLUMN created_by VARCHAR(100) DEFAULT NULL + COMMENT '创建人'; + END IF; + + -- forwarding_rules: updated_by + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME = 'updated_by' + ) THEN + ALTER TABLE forwarding_rules + ADD COLUMN updated_by VARCHAR(100) DEFAULT NULL + COMMENT '更新人'; + END IF; + + -- ======================================== + -- dns_zone_config: created_by + -- ======================================== + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'dns_zone_config' + AND COLUMN_NAME = 'created_by' + ) THEN + ALTER TABLE dns_zone_config + ADD COLUMN created_by VARCHAR(100) DEFAULT NULL + COMMENT '创建人'; + END IF; + + -- dns_zone_config: updated_by + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'dns_zone_config' + AND COLUMN_NAME = 'updated_by' + ) THEN + ALTER TABLE dns_zone_config + ADD COLUMN updated_by VARCHAR(100) DEFAULT NULL + COMMENT '更新人'; + END IF; + + -- ======================================== + -- certificates: created_by + -- ======================================== + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'certificates' + AND COLUMN_NAME = 'created_by' + ) THEN + ALTER TABLE certificates + ADD COLUMN created_by VARCHAR(100) DEFAULT NULL + COMMENT '创建人'; + END IF; + + -- certificates: updated_by + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'certificates' + AND COLUMN_NAME = 'updated_by' + ) THEN + ALTER TABLE certificates + ADD COLUMN updated_by VARCHAR(100) DEFAULT NULL + COMMENT '更新人'; + END IF; +END$$ +DELIMITER ; + +-- 执行存储过程 +CALL add_audit_columns(); + +-- 清理 +DROP PROCEDURE IF EXISTS add_audit_columns; + +-- ════════════════════════════════════════════════ +-- 新建 DNS 记录本地审计表 +-- ════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS dns_records ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + record_id VARCHAR(128) NOT NULL COMMENT '阿里云解析记录ID', + domain_zone VARCHAR(255) NOT NULL COMMENT '主域名(如 zgitm.com)', + rr VARCHAR(255) NOT NULL COMMENT '主机记录', + record_type VARCHAR(20) NOT NULL COMMENT '记录类型: A / CNAME / TXT', + record_value VARCHAR(500) NOT NULL COMMENT '记录值(IP 或域名)', + ttl INT DEFAULT 600 COMMENT 'TTL 生效时间(秒)', + status TINYINT DEFAULT 1 COMMENT '状态: 0-已删除(逻辑删除), 1-正常', + created_by VARCHAR(100) DEFAULT NULL COMMENT '创建人', + updated_by VARCHAR(100) DEFAULT NULL COMMENT '更新人', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_record_id (record_id), + INDEX idx_domain_zone (domain_zone), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='DNS解析记录本地审计表(阿里云为权威数据源,本表仅做审计追踪)'; + +-- ════════════════════════════════════════════════ +-- 验证结果 +-- ════════════════════════════════════════════════ +SELECT '=== forwarding_rules 审计列 ===' AS ''; +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME IN ('created_by', 'updated_by'); + +SELECT '=== dns_zone_config 审计列 ===' AS ''; +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'dns_zone_config' + AND COLUMN_NAME IN ('created_by', 'updated_by'); + +SELECT '=== certificates 审计列 ===' AS ''; +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'certificates' + AND COLUMN_NAME IN ('created_by', 'updated_by'); + +SELECT '=== dns_records 表结构 ===' AS ''; +SHOW CREATE TABLE dns_records; diff --git a/sql/cert_migration.sql b/sql/cert_migration.sql new file mode 100644 index 0000000..9201434 --- /dev/null +++ b/sql/cert_migration.sql @@ -0,0 +1,37 @@ +-- ============================================ +-- 证书管理功能 - 数据库迁移脚本 +-- 执行日期: 2026-06-11 +-- ============================================ + +USE nginxserver; + +-- 1. 给 certificates 表增加通知相关字段 +ALTER TABLE certificates + ADD COLUMN IF NOT EXISTS cert_type VARCHAR(20) DEFAULT 'internal_ca' COMMENT '证书类型: internal_ca / letsencrypt', + ADD COLUMN IF NOT EXISTS notify_email VARCHAR(500) DEFAULT NULL COMMENT '通知收件人邮箱,多个用逗号分隔', + ADD COLUMN IF NOT EXISTS notify_enabled TINYINT DEFAULT 1 COMMENT '是否启用到期通知: 0-禁用, 1-启用', + ADD COLUMN IF NOT EXISTS notify_days_before INT DEFAULT 15 COMMENT '提前多少天开始通知', + ADD COLUMN IF NOT EXISTS last_renew_time DATETIME DEFAULT NULL COMMENT '上次续期时间', + ADD COLUMN IF NOT EXISTS renew_log TEXT DEFAULT NULL COMMENT '续期/签发日志(JSON)'; + +-- 2. 创建证书通知发送日志表 +CREATE TABLE IF NOT EXISTS cert_notify_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + cert_id BIGINT NOT NULL COMMENT '关联 certificates.id', + domain VARCHAR(255) NOT NULL COMMENT '域名', + days_left INT NOT NULL COMMENT '到期剩余天数', + email_to VARCHAR(500) NOT NULL COMMENT '收件人邮箱', + success TINYINT DEFAULT 1 COMMENT '是否发送成功: 0-失败, 1-成功', + response_msg VARCHAR(1000) DEFAULT NULL COMMENT '网关API返回信息', + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间', + INDEX idx_cert_id (cert_id), + INDEX idx_domain (domain), + INDEX idx_sent_at (sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书到期通知发送日志'; + +-- 3. 插入 zgitm.com 系统证书记录(Let's Encrypt 类型) +INSERT IGNORE INTO certificates (domain, cert_type, notify_email, notify_enabled, notify_days_before, status) +VALUES ('zgitm.com', 'letsencrypt', 'admin@mokee.com', 1, 15, 'active'); + +INSERT IGNORE INTO certificates (domain, cert_type, notify_email, notify_enabled, notify_days_before, status) +VALUES ('www.zgitm.com', 'letsencrypt', 'admin@mokee.com', 1, 15, 'active'); diff --git a/sql/gateway_init.sql b/sql/gateway_init.sql new file mode 100644 index 0000000..2d7a5cf --- /dev/null +++ b/sql/gateway_init.sql @@ -0,0 +1,70 @@ +-- ============================================= +-- nginx配置管理平台 菜单 & 角色初始化 SQL +-- 系统编码: nginxServer +-- 执行位置: Mokee Gateway 数据库 +-- ============================================= + +-- 0. 获取系统 ID +SET @system_id = (SELECT id FROM sys_system WHERE system_code = 'nginxServer' AND is_deleted = 0 LIMIT 1); + +-- 1. 创建顶级菜单目录(用变量持有父菜单 UUID,子菜单直接引用) +SET @menu_nginx = REPLACE(UUID(),'-',''); + +INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status) VALUES +(@menu_nginx, @system_id, '0', 'Nginx代理管理', '/nginx', 'Layout', 'Connection', 1, 1, 1); + +-- 2. 创建子菜单(parent_id 直接用上面定义的变量) +INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status) VALUES +(REPLACE(UUID(),'-',''), @system_id, @menu_nginx, 'Nginx转发管理', '/nginx-forwarding', 'views/NginxForwarding.vue', 'Connection', 2, 1, 1), +(REPLACE(UUID(),'-',''), @system_id, @menu_nginx, 'SSL证书管理', '/cert-management', 'views/CertManagement.vue', 'Key', 2, 2, 1), +(REPLACE(UUID(),'-',''), @system_id, @menu_nginx, 'DNS解析管理', '/dns-management', 'views/DnsManagement.vue', 'Link', 2, 3, 1); + +-- 3. 创建角色(用变量持有角色 UUID) +SET @role_admin = REPLACE(UUID(),'-',''); +SET @role_user = REPLACE(UUID(),'-',''); + +INSERT INTO sys_role (id, system_id, role_name, role_code, description, status) VALUES +(@role_admin, @system_id, '管理员', 'admin', '拥有全部权限', 1), +(@role_user, @system_id, '普通用户', 'user', '拥有基本权限', 1); + +-- 4. 将本系统所有菜单分配给管理员角色 +INSERT INTO sys_role_menu (id, role_id, menu_id) +SELECT REPLACE(UUID(),'-',''), @role_admin, m.id +FROM sys_menu m WHERE m.system_id = @system_id; + +-- ============================================= +-- 创建网关应用管理员账号 +-- 角色: gateway-app-admin (只能看到自己绑定系统的数据) +-- ============================================= + +-- 5. 创建管理员用户(密码 admin123 的 BCrypt 密文,建议自行生成替换) +INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted) +SELECT REPLACE(UUID(),'-',''), 'admin_nginxServer', + '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', + 'Nginx配置管理平台管理员', 'admin@nginxServer.com', NULL, 1, 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_nginxServer'); + +-- 6. 绑定管理员到 gateway 系统 +INSERT INTO sys_user_system (id, user_id, system_id) +SELECT REPLACE(UUID(),'-',''), u.id, s.id +FROM sys_user u, sys_system s +WHERE u.username = 'admin_nginxServer' + AND s.system_code = 'gateway' + AND NOT EXISTS ( + SELECT 1 FROM sys_user_system us + WHERE us.user_id = u.id AND us.system_id = s.id + ); + +-- 7. 分配 gateway-app-admin 角色(gateway 系统下的应用管理员角色) +INSERT INTO sys_user_role (id, user_id, role_id) +SELECT REPLACE(UUID(),'-',''), u.id, r.id +FROM sys_user u, sys_role r, sys_system s +WHERE u.username = 'admin_nginxServer' + AND r.role_code = 'gateway-app-admin' + AND r.system_id = s.id + AND s.system_code = 'gateway' + AND NOT EXISTS ( + SELECT 1 FROM sys_user_role ur + WHERE ur.user_id = u.id AND ur.role_id = r.id + ); diff --git a/sql/gzip_migration.sql b/sql/gzip_migration.sql new file mode 100644 index 0000000..9ef0172 --- /dev/null +++ b/sql/gzip_migration.sql @@ -0,0 +1,78 @@ +-- ============================================ +-- 内容压缩(gzip)配置字段迁移 +-- 数据库: nginxserver @ 10.1.1.122:3306 +-- 日期: 2026-06-14 +-- 执行方式: mysql -h 10.1.1.122 -u nginxserver -p nginxserver < gzip_migration.sql +-- ============================================ + +USE nginxserver; + +-- 安全添加列(检查是否已存在,避免重复执行报错) +DROP PROCEDURE IF EXISTS add_gzip_columns; + +DELIMITER $$ +CREATE PROCEDURE add_gzip_columns() +BEGIN + -- gzip_enabled + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME = 'gzip_enabled' + ) THEN + ALTER TABLE forwarding_rules + ADD COLUMN gzip_enabled TINYINT(1) DEFAULT 0 + COMMENT 'gzip压缩开关: 0-关闭, 1-开启'; + END IF; + + -- gzip_types + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME = 'gzip_types' + ) THEN + ALTER TABLE forwarding_rules + ADD COLUMN gzip_types VARCHAR(500) + DEFAULT 'text/plain text/css text/xml text/javascript application/x-javascript application/json application/xml application/xml+rss application/vnd.ms-fontobject application/x-font-ttf application/x-font-opentype application/x-font-truetype' + COMMENT 'gzip压缩MIME类型(空格分隔)'; + END IF; + + -- gzip_comp_level + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME = 'gzip_comp_level' + ) THEN + ALTER TABLE forwarding_rules + ADD COLUMN gzip_comp_level TINYINT DEFAULT 6 + COMMENT 'gzip压缩级别 1-9'; + END IF; + + -- gzip_min_length + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME = 'gzip_min_length' + ) THEN + ALTER TABLE forwarding_rules + ADD COLUMN gzip_min_length VARCHAR(10) DEFAULT '1000' + COMMENT 'gzip最小压缩长度(可带k/m单位)'; + END IF; +END$$ +DELIMITER ; + +-- 执行存储过程 +CALL add_gzip_columns(); + +-- 清理 +DROP PROCEDURE IF EXISTS add_gzip_columns; + +-- 验证结果 +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'nginxserver' + AND TABLE_NAME = 'forwarding_rules' + AND COLUMN_NAME LIKE 'gzip%'; diff --git a/sql/init.sql b/sql/init.sql new file mode 100644 index 0000000..71e6b96 --- /dev/null +++ b/sql/init.sql @@ -0,0 +1,48 @@ +-- ============================================ +-- Nginx域名转发管理平台 - 数据库初始化脚本 +-- 数据库: nginxserver +-- MySQL: 10.1.1.122:3306 +-- ============================================ + +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS nginxserver + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE nginxserver; + +-- ============================================ +-- 转发规则表 +-- ============================================ +CREATE TABLE IF NOT EXISTS forwarding_rules ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + domain VARCHAR(255) NOT NULL COMMENT '域名', + protocol VARCHAR(10) NOT NULL COMMENT '协议类型: http / https', + target VARCHAR(500) NOT NULL COMMENT '转发目标地址 (http://或https://开头)', + config_file VARCHAR(500) DEFAULT NULL COMMENT 'Nginx配置文件路径', + ssl_cert VARCHAR(500) DEFAULT NULL COMMENT 'SSL证书文件路径', + ssl_key VARCHAR(500) DEFAULT NULL COMMENT 'SSL私钥文件路径', + status TINYINT DEFAULT 1 COMMENT '状态: 0-已删除, 1-正常', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_domain (domain), + INDEX idx_status (status), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Nginx转发规则表'; + +-- ============================================ +-- SSL证书管理表 +-- ============================================ +CREATE TABLE IF NOT EXISTS certificates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + domain VARCHAR(255) NOT NULL COMMENT '域名', + cert_path VARCHAR(500) DEFAULT NULL COMMENT '证书文件路径 (.pem)', + key_path VARCHAR(500) DEFAULT NULL COMMENT '私钥文件路径 (.key)', + issue_date DATETIME DEFAULT NULL COMMENT '证书签发日期', + expire_date DATETIME DEFAULT NULL COMMENT '证书过期日期', + status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active / expired / revoked', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_cert_domain (domain), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SSL证书管理表'; diff --git a/test_email.py b/test_email.py new file mode 100644 index 0000000..401e58a --- /dev/null +++ b/test_email.py @@ -0,0 +1,35 @@ +import urllib.request, json + +# 获取 token +req = urllib.request.Request( + 'https://gw.server.zgitm.com/zgapi/v1/open/token', + data=json.dumps({"systemCode": "nginxServer"}).encode('utf-8'), + headers={'Content-Type': 'application/json; charset=UTF-8'}, + method='POST' +) +resp = json.loads(urllib.request.urlopen(req, timeout=10).read()) +print("Token获取:", resp['code'], resp['msg']) + +token = resp['data']['token'] +print(f"systemName: {resp['data']['systemName']}") + +# 发送邮件 +req2 = urllib.request.Request( + 'https://gw.server.zgitm.com/zgapi/v1/open/email/send', + data=json.dumps({ + "to": "zg@zgitm.com", + "subject": "测试", + "content": "

测试邮件

", + "contentType": "html" + }).encode('utf-8'), + headers={ + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': f'Bearer {token}' + }, + method='POST' +) +try: + resp2 = json.loads(urllib.request.urlopen(req2, timeout=10).read()) + print(f"邮件发送: code={resp2['code']}, msg={resp2['msg']}") +except urllib.error.HTTPError as e: + print(f"邮件发送失败: {e.code} - {e.read().decode()}")