初始化1
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ test/
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
.idea
|
||||
519
DEPLOYMENT.md
Normal file
519
DEPLOYMENT.md
Normal file
@@ -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/ # 转发规则配置目录
|
||||
│ └── <domain>.conf # 各域名的转发配置(由API自动生成)
|
||||
├── ssl/ # SSL证书目录
|
||||
│ ├── ca/ # 内部根CA
|
||||
│ │ ├── ca.crt # CA证书(有效期10年)
|
||||
│ │ ├── ca.key # CA私钥(权限600)
|
||||
│ │ └── ca.srl # 序列号文件
|
||||
│ ├── <domain>.pem # 域名SSL证书
|
||||
│ └── <domain>.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/<domain> | 获取单个规则 |
|
||||
| POST | /api/nginx/rules | 新增规则 |
|
||||
| PUT | /api/nginx/rules/<domain> | 修改规则 |
|
||||
| DELETE | /api/nginx/rules/<domain> | 删除规则 |
|
||||
| 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/<domain>.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name <domain>;
|
||||
|
||||
location / {
|
||||
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_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTPS 转发
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/conf.d/<domain>.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name <domain>;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name <domain>;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/<domain>.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/<domain>.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
location / {
|
||||
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_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 # 生产构建
|
||||
```
|
||||
270
IMPLEMENTATION_PLAN.md
Normal file
270
IMPLEMENTATION_PLAN.md
Normal file
@@ -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/<domain> | 获取单个规则 |
|
||||
| POST | /api/nginx/rules | 新增转发规则 |
|
||||
| PUT | /api/nginx/rules/<domain> | 修改转发规则 |
|
||||
| DELETE | /api/nginx/rules/<domain> | 删除转发规则 |
|
||||
| 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/<domain>.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)
|
||||
```
|
||||
9
bash.exe.stackdump
Normal file
9
bash.exe.stackdump
Normal file
@@ -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
|
||||
126
dns-api/app.py
Normal file
126
dns-api/app.py
Normal file
@@ -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/<record_id>', 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/<record_id>', 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)
|
||||
228
dns-api/dns_service.py
Normal file
228
dns-api/dns_service.py
Normal file
@@ -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
|
||||
29
dns-api/gunicorn_config.py
Normal file
29
dns-api/gunicorn_config.py
Normal file
@@ -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
|
||||
2
dns-api/requirements.txt
Normal file
2
dns-api/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask==3.1.0
|
||||
gunicorn==23.0.0
|
||||
536
docs/nginxLinux服务器迁移手册.md
Normal file
536
docs/nginxLinux服务器迁移手册.md
Normal file
@@ -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/<domain>.conf`):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name <domain>;
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|svg|woff2|png|jpg|ico)$ {
|
||||
proxy_pass <target>;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
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_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**HTTPS** (`/etc/nginx/conf.d/<domain>.conf`):
|
||||
```nginx
|
||||
# HTTP → HTTPS 重定向
|
||||
server {
|
||||
listen 80;
|
||||
server_name <domain>;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name <domain>;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/<domain>.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/<domain>.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|svg|woff2|png|jpg|ico)$ {
|
||||
proxy_pass <target>;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
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_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/<domain>.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/<domain> → 规则详情
|
||||
POST /api/nginx/rules → 新增规则 {domain, protocol, target}
|
||||
PUT /api/nginx/rules/<domain> → 修改规则 {domain, protocol, target}
|
||||
DELETE /api/nginx/rules/<domain> → 删除规则
|
||||
POST /api/nginx/reload → 重载 Nginx
|
||||
```
|
||||
|
||||
### 证书管理
|
||||
|
||||
```
|
||||
GET /api/nginx/certs → 证书列表(扫描所有 .pem,排除 ca.*)
|
||||
GET /api/nginx/certs/<domain> → 证书详情
|
||||
POST /api/nginx/certs/<domain>/issue → 签发 LE 证书
|
||||
POST /api/nginx/certs/<domain>/renew → 续期 LE 证书
|
||||
```
|
||||
|
||||
### DNS 管理
|
||||
|
||||
```
|
||||
GET /api/nginx/dns/records?zone= → DNS 记录列表
|
||||
POST /api/nginx/dns/records → 添加 DNS 记录
|
||||
PUT /api/nginx/dns/records/<id> → 修改 DNS 记录
|
||||
DELETE /api/nginx/dns/records/<id> → 删除 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;` 不要丢失。
|
||||
6
memory/.session-last
Normal file
6
memory/.session-last
Normal file
@@ -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未完成(网络不通已回滚)。"
|
||||
}
|
||||
11
memory/MEMORY.md
Normal file
11
memory/MEMORY.md
Normal file
@@ -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 加载加速
|
||||
51
memory/session-20260611-1.md
Normal file
51
memory/session-20260611-1.md
Normal file
@@ -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 加载速度是否改善
|
||||
76
memory/session-20260611-2.md
Normal file
76
memory/session-20260611-2.md
Normal file
@@ -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)
|
||||
73
memory/session-20260612-1.md
Normal file
73
memory/session-20260612-1.md
Normal file
@@ -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页面 + 邮箱默认值)
|
||||
83
memory/session-20260612-2.md
Normal file
83
memory/session-20260612-2.md
Normal file
@@ -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/<domain>`: 移除 `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处),确保未来新增规则也生效
|
||||
96
memory/session-20260613-1.md
Normal file
96
memory/session-20260613-1.md
Normal file
@@ -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
|
||||
81
memory/session-20260614-1.md
Normal file
81
memory/session-20260614-1.md
Normal file
@@ -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/<domain>/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/<domain>/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 布局弹窗完整功能
|
||||
107
memory/session-20260614-2.md
Normal file
107
memory/session-20260614-2.md
Normal file
@@ -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/<domain>/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/<domain>/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链路
|
||||
46
memory/session-20260618-1.md
Normal file
46
memory/session-20260618-1.md
Normal file
@@ -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 链路
|
||||
88
memory/session-20260619-1.md
Normal file
88
memory/session-20260619-1.md
Normal file
@@ -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的记录定期归档
|
||||
99
memory/session-20260621-1.md
Normal file
99
memory/session-20260621-1.md
Normal file
@@ -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 卸载(用户决定时机)
|
||||
- [ ] 宝塔面板上创建更多反代项目后验证列表自动刷新
|
||||
94
memory/session-20260621-2.md
Normal file
94
memory/session-20260621-2.md
Normal file
@@ -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 服务
|
||||
5
nginx-manager-backend/Dockerfile
Normal file
5
nginx-manager-backend/Dockerfile
Normal file
@@ -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"]
|
||||
116
nginx-manager-backend/pom.xml
Normal file
116
nginx-manager-backend/pom.xml
Normal file
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.mokee</groupId>
|
||||
<artifactId>nginx-manager</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Nginx Manager Backend</name>
|
||||
<description>Nginx域名转发管理平台 - Spring Boot 后端</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<!-- 默认激活的环境: qa -->
|
||||
<spring.profiles.active>qa</spring.profiles.active>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MyBatis-Plus (Spring Boot 3.x) -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>3.5.9</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL Driver -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<!-- Maven 环境 Profiles -->
|
||||
<profiles>
|
||||
<!-- QA 测试环境 -->
|
||||
<profile>
|
||||
<id>qa</id>
|
||||
<properties>
|
||||
<spring.profiles.active>qa</spring.profiles.active>
|
||||
</properties>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
</profile>
|
||||
|
||||
<!-- PROD 生产环境 -->
|
||||
<profile>
|
||||
<id>prod</id>
|
||||
<properties>
|
||||
<spring.profiles.active>prod</spring.profiles.active>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
<!-- Maven 资源过滤:将 pom 变量 @xxx@ 替换到 application.yml -->
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- 将 Maven profile 传给 Spring Boot -->
|
||||
<jvmArguments>-Dspring.profiles.active=${spring.profiles.active}</jvmArguments>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 "系统";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.mokee.nginx.context;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 用户上下文 — ThreadLocal 缓存网关注入的请求头
|
||||
*
|
||||
* 业务代码零侵入获取当前登录用户信息:
|
||||
* <pre>
|
||||
* String userId = UserContext.getUserId();
|
||||
* String userName = UserContext.getUserName();
|
||||
* </pre>
|
||||
*
|
||||
* 由 {@link com.mokee.nginx.filter.UserContextFilter} 在请求进入时设置,
|
||||
* 请求结束后自动清理。
|
||||
*/
|
||||
public class UserContext {
|
||||
|
||||
private static final ThreadLocal<Map<String, String>> CTX = new ThreadLocal<>();
|
||||
|
||||
public static void set(Map<String, String> user) { CTX.set(user); }
|
||||
public static Map<String, String> get() {
|
||||
Map<String, String> 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"); }
|
||||
}
|
||||
@@ -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<ApiResponse<List<Certificate>>> listCertificates() {
|
||||
log.info("获取证书列表");
|
||||
List<Certificate> certs = certificateService.listCertificates();
|
||||
return ResponseEntity.ok(ApiResponse.success(certs));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个证书详情
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Certificate>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> renewCertificate(@PathVariable Long id) {
|
||||
log.warn("续期证书请求被拒绝(请到宝塔面板操作): id={}", id);
|
||||
return ResponseEntity.status(405).body(
|
||||
ApiResponse.error(405, "证书续期请在宝塔面板上操作(SSL → 续期)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知配置
|
||||
*/
|
||||
@PutMapping("/{id}/notify-config")
|
||||
public ResponseEntity<ApiResponse<Certificate>> updateNotifyConfig(
|
||||
@PathVariable Long id, @RequestBody Map<String, Object> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<ApiResponse<List<DnsZoneConfig>>> listZones() {
|
||||
List<DnsZoneConfig> zones = zoneConfigMapper.selectList(null);
|
||||
return ResponseEntity.ok(ApiResponse.success(zones));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 DNS 解析记录列表(含本地审计信息:创建人/时间/更新人/时间)
|
||||
*/
|
||||
@GetMapping("/records")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> listRecords(
|
||||
@RequestParam(defaultValue = "zgitm.com") String zone) {
|
||||
log.info("获取DNS记录: zone={}", zone);
|
||||
List<Map<String, Object>> records = dnsRecordService.listRecords(zone);
|
||||
return ResponseEntity.ok(ApiResponse.success(records));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 DNS 解析记录
|
||||
*/
|
||||
@PostMapping("/records")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addRecord(
|
||||
@RequestBody Map<String, Object> body) {
|
||||
log.info("添加DNS记录: {}", body);
|
||||
Map<String, Object> result = dnsRecordService.addRecord(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 DNS 解析记录
|
||||
*/
|
||||
@DeleteMapping("/records/{recordId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteRecord(
|
||||
@PathVariable String recordId) {
|
||||
log.info("删除DNS记录: recordId={}", recordId);
|
||||
Map<String, Object> result = dnsRecordService.deleteRecord(recordId);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 DNS 解析记录
|
||||
*/
|
||||
@PutMapping("/records/{recordId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateRecord(
|
||||
@PathVariable String recordId, @RequestBody Map<String, Object> body) {
|
||||
log.info("修改DNS记录: recordId={}, body={}", recordId, body);
|
||||
Map<String, Object> result = dnsRecordService.updateRecord(recordId, body);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认 zone 配置(前端默认值用)
|
||||
*/
|
||||
@GetMapping("/default-zone")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDefaultZone() {
|
||||
DnsZoneConfig zone = zoneConfigMapper.selectOne(
|
||||
new LambdaQueryWrapper<DnsZoneConfig>()
|
||||
.eq(DnsZoneConfig::getDomainZone, "zgitm.com")
|
||||
.eq(DnsZoneConfig::getStatus, 1));
|
||||
Map<String, Object> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<ApiResponse<List<ForwardingRule>>> listRules() {
|
||||
log.info("获取转发规则列表(数据源: 宝塔面板)");
|
||||
List<ForwardingRule> rules = service.listRules();
|
||||
return ResponseEntity.ok(ApiResponse.success(rules));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取规则详情(从当前列表中匹配)
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<ForwardingRule>> 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<ApiResponse<Void>> addRule(@Valid @RequestBody RuleRequest request) {
|
||||
log.warn("新增规则请求被拒绝(请到宝塔面板操作): domain={}", request.getDomain());
|
||||
return ResponseEntity.status(405).body(
|
||||
ApiResponse.error(405, "新增转发规则请在宝塔面板上操作(站点 → 反向代理)"));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> updateRule(
|
||||
@PathVariable Long id, @Valid @RequestBody RuleRequest request) {
|
||||
log.warn("修改规则请求被拒绝(请到宝塔面板操作): id={}", id);
|
||||
return ResponseEntity.status(405).body(
|
||||
ApiResponse.error(405, "修改转发规则请在宝塔面板上操作(站点 → 反向代理)"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteRule(@PathVariable Long id) {
|
||||
log.warn("删除规则请求被拒绝(请到宝塔面板操作): id={}", id);
|
||||
return ResponseEntity.status(405).body(
|
||||
ApiResponse.error(405, "删除转发规则请在宝塔面板上操作(站点 → 反向代理)"));
|
||||
}
|
||||
|
||||
@PostMapping("/reload")
|
||||
public ResponseEntity<ApiResponse<Void>> reloadNginx() {
|
||||
log.warn("重载Nginx请求被拒绝(请到宝塔面板操作)");
|
||||
return ResponseEntity.status(405).body(
|
||||
ApiResponse.error(405, "重载Nginx请在宝塔面板上操作"));
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<ApiResponse<String>> getStatus() {
|
||||
log.info("Nginx状态查询(已迁移至宝塔)");
|
||||
return ResponseEntity.ok(ApiResponse.success("Nginx 反代已迁移至宝塔面板管理"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取规则详情(配置文件内容 + SSL证书信息)
|
||||
*/
|
||||
@GetMapping("/{id}/config")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getConfig(@PathVariable Long id) {
|
||||
log.info("获取规则详情: id={}", id);
|
||||
Map<String, Object> result = service.getConfig(id);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/config")
|
||||
public ResponseEntity<ApiResponse<Void>> updateConfig(
|
||||
@PathVariable Long id, @RequestBody Object body) {
|
||||
return ResponseEntity.status(410).body(
|
||||
ApiResponse.error(410, "配置文件编辑已不可用,请前往宝塔面板"));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/ssl")
|
||||
public ResponseEntity<ApiResponse<Void>> getSslFiles(@PathVariable Long id) {
|
||||
return ResponseEntity.status(410).body(
|
||||
ApiResponse.error(410, "SSL证书查看请前往宝塔面板"));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/gzip")
|
||||
public ResponseEntity<ApiResponse<Void>> getGzipConfig(@PathVariable Long id) {
|
||||
return ResponseEntity.status(410).body(
|
||||
ApiResponse.error(410, "内容压缩配置请前往宝塔面板查看"));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/gzip")
|
||||
public ResponseEntity<ApiResponse<Void>> updateGzipConfig(
|
||||
@PathVariable Long id, @RequestBody Object body) {
|
||||
return ResponseEntity.status(410).body(
|
||||
ApiResponse.error(410, "内容压缩配置请前往宝塔面板修改"));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/logs")
|
||||
public ResponseEntity<ApiResponse<Void>> getDomainLogs(
|
||||
@PathVariable Long id, @RequestParam(defaultValue = "200") int lines) {
|
||||
return ResponseEntity.status(410).body(
|
||||
ApiResponse.error(410, "日志查看请前往宝塔面板"));
|
||||
}
|
||||
}
|
||||
@@ -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<ApiResponse<Void>> 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<ApiResponse<Void>> handleRuntimeException(RuntimeException ex) {
|
||||
log.error("业务异常: {}", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error(ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 其他未捕获异常
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
|
||||
log.error("系统异常: ", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("系统内部错误: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(0)
|
||||
.message("success")
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(0)
|
||||
.message(message)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.data(null)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(-1)
|
||||
.message(message)
|
||||
.data(null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Certificate> {
|
||||
}
|
||||
@@ -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<DnsRecord> {
|
||||
}
|
||||
@@ -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<DnsZoneConfig> {
|
||||
}
|
||||
@@ -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<ForwardingRule> {
|
||||
}
|
||||
@@ -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<String, Object> postRequest(String route, String action,
|
||||
MultiValueMap<String, String> 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<String> entity = new HttpEntity<>("", headers);
|
||||
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<Map<String, Object>> postRequestForList(String route, String action,
|
||||
MultiValueMap<String, String> extraParams) {
|
||||
String url = buildUrl(route, action, extraParams);
|
||||
log.debug("宝塔API请求(List): POST {}", url);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
HttpEntity<String> entity = new HttpEntity<>("", headers);
|
||||
|
||||
try {
|
||||
ResponseEntity<List<Map<String, Object>>> 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<String, String> 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<String, List<String>> 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<String, Object> getSiteList() {
|
||||
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
|
||||
params.add("table", "sites");
|
||||
params.add("p", "1");
|
||||
params.add("limit", "200");
|
||||
params.add("type", "-1");
|
||||
|
||||
Map<String, Object> result = postRequest("/data", "getData", params);
|
||||
log.info("宝塔API: 获取站点列表成功");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定站点的反向代理列表
|
||||
* API: POST /site?action=GetProxyList
|
||||
*
|
||||
* 参数: sitename=域名
|
||||
* 返回: [{"proxyname":"...","proxydir":"/","proxysite":"http://...",...}]
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getProxyList(String siteName) {
|
||||
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
|
||||
params.add("sitename", siteName);
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> 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<Map<String, Object>> getCertOrders() {
|
||||
try {
|
||||
List<Map<String, Object>> 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<String, String> params = new LinkedMultiValueMap<>();
|
||||
params.add("path", filePath);
|
||||
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Certificate> certs = certificateMapper.selectList(
|
||||
new LambdaQueryWrapper<Certificate>()
|
||||
.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<String, String> body = new HashMap<>();
|
||||
body.put("systemCode", systemCode);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
|
||||
|
||||
ResponseEntity<Map> response = gatewayRestTemplate.postForEntity(
|
||||
url, request, Map.class);
|
||||
|
||||
Map<String, Object> data = (Map<String, Object>) response.getBody();
|
||||
if (data != null && Integer.valueOf(200).equals(data.get("code"))) {
|
||||
Map<String, Object> inner = (Map<String, Object>) 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<String, String> 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<Map<String, String>> request = new HttpEntity<>(body, headers);
|
||||
|
||||
ResponseEntity<Map> response = gatewayRestTemplate.postForEntity(
|
||||
url, request, Map.class);
|
||||
|
||||
Map<String, Object> 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(
|
||||
"<!DOCTYPE html>" +
|
||||
"<html><body style=\"font-family:'Microsoft YaHei',Arial,sans-serif;\">" +
|
||||
"<div style=\"max-width:600px;margin:0 auto;padding:20px;border:1px solid #EBEEF5;border-radius:8px;\">" +
|
||||
"<div style=\"background:%s;padding:16px;border-radius:8px 8px 0 0;text-align:center;\">" +
|
||||
"<h2 style=\"color:#fff;margin:0;\">⚠️ SSL证书即将到期 [%s]</h2></div>" +
|
||||
"<div style=\"padding:20px;\">" +
|
||||
"<table style=\"width:100%%;border-collapse:collapse;\">" +
|
||||
"<tr><td style=\"padding:8px 0;color:#909399;width:80px;\">域名</td>" +
|
||||
"<td style=\"padding:8px 0;font-weight:bold;font-size:16px;\">%s</td></tr>" +
|
||||
"<tr><td style=\"padding:8px 0;color:#909399;\">到期时间</td>" +
|
||||
"<td style=\"padding:8px 0;\">%s</td></tr>" +
|
||||
"<tr><td style=\"padding:8px 0;color:#909399;\">剩余天数</td>" +
|
||||
"<td style=\"padding:8px 0;color:%s;font-weight:bold;font-size:20px;\">%d 天</td></tr>" +
|
||||
"<tr><td style=\"padding:8px 0;color:#909399;\">证书类型</td>" +
|
||||
"<td style=\"padding:8px 0;\">Let's Encrypt (阿里云DNS验证)</td></tr>" +
|
||||
"</table>" +
|
||||
"<hr style=\"border:0;border-top:1px solid #EBEEF5;margin:16px 0;\">" +
|
||||
"<p style=\"color:#909399;font-size:13px;\">系统已配置 acme.sh 自动续期。" +
|
||||
"如续期失败请检查阿里云 AccessKey 和网络连通性。</p>" +
|
||||
"<p style=\"color:#C0C4CC;font-size:12px;\">此邮件由 Nginx域名管理平台自动发送</p>" +
|
||||
"</div></div></body></html>",
|
||||
color, level, domain,
|
||||
expireDate.toLocalDate().toString(),
|
||||
color, daysLeft);
|
||||
}
|
||||
}
|
||||
@@ -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<Certificate> listCertificates() {
|
||||
List<Certificate> certs = new ArrayList<>();
|
||||
long index = 1;
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> orders = btPanelApiClient.getCertOrders();
|
||||
|
||||
if (orders == null || orders.isEmpty()) {
|
||||
log.info("宝塔证书订单列表为空");
|
||||
return certs;
|
||||
}
|
||||
|
||||
log.info("宝塔证书订单数量: {}", orders.size());
|
||||
|
||||
for (Map<String, Object> order : orders) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> domains = (List<String>) 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<Certificate>()
|
||||
.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<Certificate> 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<Certificate> allCerts = listCertificates();
|
||||
Certificate btCert = allCerts.stream()
|
||||
.filter(c -> c.getId().equals(id))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("证书不存在: id=" + id));
|
||||
|
||||
// 查找或创建本地通知配置记录
|
||||
Certificate localCert = certificateMapper.selectOne(
|
||||
new LambdaQueryWrapper<Certificate>()
|
||||
.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)");
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Object>> listRecords(String zone) {
|
||||
// 1. 从阿里云获取权威 DNS 记录
|
||||
Map<String, Object> aliyunResult = pythonApiClient.listDnsRecords(zone);
|
||||
Object dataObj = aliyunResult.get("data");
|
||||
List<Map<String, Object>> aliyunRecords;
|
||||
if (dataObj instanceof List) {
|
||||
aliyunRecords = (List<Map<String, Object>>) dataObj;
|
||||
} else if (dataObj instanceof Map) {
|
||||
// 兼容 Python API 返回格式:{ data: { data: [...] } }
|
||||
Object inner = ((Map<String, Object>) dataObj).get("data");
|
||||
if (inner instanceof List) {
|
||||
aliyunRecords = (List<Map<String, Object>>) inner;
|
||||
} else {
|
||||
aliyunRecords = new ArrayList<>();
|
||||
}
|
||||
} else {
|
||||
aliyunRecords = new ArrayList<>();
|
||||
}
|
||||
|
||||
if (aliyunRecords.isEmpty()) {
|
||||
return aliyunRecords;
|
||||
}
|
||||
|
||||
// 2. 查询本地审计表
|
||||
List<String> recordIds = aliyunRecords.stream()
|
||||
.map(r -> String.valueOf(r.get("record_id")))
|
||||
.filter(id -> !id.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, DnsRecord> auditMap = new HashMap<>();
|
||||
if (!recordIds.isEmpty()) {
|
||||
List<DnsRecord> localRecords = dnsRecordMapper.selectList(
|
||||
new LambdaQueryWrapper<DnsRecord>()
|
||||
.in(DnsRecord::getRecordId, recordIds)
|
||||
.eq(DnsRecord::getStatus, 1));
|
||||
auditMap = localRecords.stream()
|
||||
.collect(Collectors.toMap(DnsRecord::getRecordId, r -> r, (a, b) -> a));
|
||||
}
|
||||
|
||||
// 3. 合并审计字段
|
||||
for (Map<String, Object> 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<String, Object> addRecord(Map<String, Object> body) {
|
||||
// 1. 调用阿里云 API 创建
|
||||
Map<String, Object> 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<String, Object> updateRecord(String recordId, Map<String, Object> body) {
|
||||
// 1. 调用阿里云 API 更新
|
||||
Map<String, Object> result = pythonApiClient.updateDnsRecord(recordId, body);
|
||||
|
||||
// 2. 更新本地审计记录
|
||||
DnsRecord localRecord = dnsRecordMapper.selectOne(
|
||||
new LambdaQueryWrapper<DnsRecord>()
|
||||
.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<String, Object> deleteRecord(String recordId) {
|
||||
// 1. 调用阿里云 API 删除
|
||||
Map<String, Object> result = pythonApiClient.deleteDnsRecord(recordId);
|
||||
|
||||
// 2. 本地软删除
|
||||
DnsRecord localRecord = dnsRecordMapper.selectOne(
|
||||
new LambdaQueryWrapper<DnsRecord>()
|
||||
.eq(DnsRecord::getRecordId, recordId)
|
||||
.eq(DnsRecord::getStatus, 1));
|
||||
if (localRecord != null) {
|
||||
dnsRecordMapper.deleteById(localRecord.getId());
|
||||
log.info("DNS 审计记录已软删除: recordId={}", recordId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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<ForwardingRule> listRules() {
|
||||
List<ForwardingRule> rules = new ArrayList<>();
|
||||
long index = 1;
|
||||
|
||||
try {
|
||||
// 1. 获取站点列表
|
||||
Map<String, Object> siteResult = btPanelApiClient.getSiteList();
|
||||
List<Map<String, Object>> sites = siteResult != null
|
||||
? (List<Map<String, Object>>) siteResult.get("data")
|
||||
: null;
|
||||
|
||||
if (sites == null || sites.isEmpty()) {
|
||||
log.info("宝塔站点列表为空");
|
||||
return rules;
|
||||
}
|
||||
|
||||
log.info("宝塔站点数量: {}", sites.size());
|
||||
|
||||
// 2. 遍历每个站点
|
||||
for (Map<String, Object> 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<Map<String, Object>> proxies = btPanelApiClient.getProxyList(siteName);
|
||||
|
||||
if (proxies != null && !proxies.isEmpty()) {
|
||||
for (Map<String, Object> 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<String, Object> site, boolean isRunning) {
|
||||
try {
|
||||
String configJson = (String) site.get("project_config");
|
||||
if (configJson == null || configJson.isEmpty()) return null;
|
||||
|
||||
Map<String, Object> 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<String, Object> firstProxy = (Map<String, Object>) ((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<String, Object> site, Map<String, Object> 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<String, Object> getConfig(Long id) {
|
||||
// 从列表中匹配域名
|
||||
List<ForwardingRule> rules = listRules();
|
||||
ForwardingRule rule = rules.stream()
|
||||
.filter(r -> r.getId().equals(id))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("规则不存在: id=" + id));
|
||||
|
||||
String domain = rule.getDomain();
|
||||
Map<String, Object> 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<Map<String, Object>> orders = btPanelApiClient.getCertOrders();
|
||||
Map<String, Object> matchedCert = null;
|
||||
if (orders != null) {
|
||||
for (Map<String, Object> order : orders) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> domains = (List<String>) 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<String, Object> updateConfig(Long id, String content) {
|
||||
throw new UnsupportedOperationException("配置文件编辑已不可用");
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Map<String, Object> getSslFiles(Long id) {
|
||||
throw new UnsupportedOperationException("SSL证书查看请前往宝塔面板");
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Map<String, Object> getGzipConfig(Long id) {
|
||||
throw new UnsupportedOperationException("内容压缩配置请前往宝塔面板查看");
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Map<String, Object> updateGzipConfig(Long id, Map<String, Object> gzipConfig) {
|
||||
throw new UnsupportedOperationException("内容压缩配置请前往宝塔面板修改");
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Map<String, Object> getDomainLogs(Long id, int lines) {
|
||||
throw new UnsupportedOperationException("日志查看请前往宝塔面板");
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Map<String, Object> getNginxStatus() {
|
||||
throw new UnsupportedOperationException("Nginx状态请前往宝塔面板查看");
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> getRules() {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> addRule(RuleRequest request) {
|
||||
try {
|
||||
Map<String, Object> 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<Map<String, Object>> 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<String, Object> updateRule(String domain, RuleRequest request) {
|
||||
try {
|
||||
Map<String, Object> body = Map.of(
|
||||
"domain", request.getDomain(),
|
||||
"protocol", request.getProtocol(),
|
||||
"target", request.getTarget()
|
||||
);
|
||||
RestTemplate rt = "https".equals(request.getProtocol()) ? certRestTemplate : restTemplate;
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> deleteRule(String domain) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> reloadNginx() {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> getRuleConfig(String domain) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> updateRuleConfig(String domain, String content) {
|
||||
try {
|
||||
Map<String, Object> body = Map.of("content", content);
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> getSslFiles(String domain) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> getGzipConfig(String domain) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> updateGzipConfig(String domain, Map<String, Object> gzipConfig) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> getDomainLogs(String domain, int lines) {
|
||||
try {
|
||||
String path = "/api/nginx/rules/" + domain + "/logs?lines=" + lines;
|
||||
ResponseEntity<Map<String, Object>> 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<Map<String, Object>> response = restTemplate.exchange(
|
||||
"/api/nginx/health",
|
||||
HttpMethod.GET,
|
||||
null,
|
||||
new ParameterizedTypeReference<>() {}
|
||||
);
|
||||
Map<String, Object> 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<String, Object> getCertInfo(String domain) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
|
||||
"/api/nginx/certs/{domain}",
|
||||
HttpMethod.GET,
|
||||
null,
|
||||
new ParameterizedTypeReference<>() {},
|
||||
domain
|
||||
);
|
||||
Map<String, Object> body = response.getBody();
|
||||
return body != null ? (Map<String, Object>) body.get("data") : null;
|
||||
} catch (Exception e) {
|
||||
log.error("获取证书信息失败: {}", e.getMessage());
|
||||
throw new RuntimeException("获取证书信息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发 Let's Encrypt 证书(超时: 5分钟)
|
||||
*/
|
||||
public Map<String, Object> issueCert(String domain) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> renewCert(String domain) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> listCerts() {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> listDnsRecords(String zone) {
|
||||
try {
|
||||
String path = "/api/nginx/dns/records?zone=" + zone;
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> addDnsRecord(Map<String, Object> body) {
|
||||
try {
|
||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body);
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> deleteDnsRecord(String recordId) {
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> updateDnsRecord(String recordId, Map<String, Object> body) {
|
||||
try {
|
||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body);
|
||||
ResponseEntity<Map<String, Object>> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
15
nginx-manager-backend/src/main/resources/application-qa.yml
Normal file
15
nginx-manager-backend/src/main/resources/application-qa.yml
Normal file
@@ -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
|
||||
47
nginx-manager-backend/src/main/resources/application.yml
Normal file
47
nginx-manager-backend/src/main/resources/application.yml
Normal file
@@ -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
|
||||
5
nginx-manager-frontend/.env.development
Normal file
5
nginx-manager-frontend/.env.development
Normal file
@@ -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转发管理平台
|
||||
5
nginx-manager-frontend/.env.production
Normal file
5
nginx-manager-frontend/.env.production
Normal file
@@ -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转发管理平台
|
||||
24
nginx-manager-frontend/.gitignore
vendored
Normal file
24
nginx-manager-frontend/.gitignore
vendored
Normal file
@@ -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?
|
||||
3
nginx-manager-frontend/.vscode/extensions.json
vendored
Normal file
3
nginx-manager-frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
4
nginx-manager-frontend/Dockerfile
Normal file
4
nginx-manager-frontend/Dockerfile
Normal file
@@ -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
|
||||
5
nginx-manager-frontend/README.md
Normal file
5
nginx-manager-frontend/README.md
Normal file
@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
15
nginx-manager-frontend/index.html
Normal file
15
nginx-manager-frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nginx域名转发管理平台</title>
|
||||
<!-- Mokee UserChip Widget 样式 -->
|
||||
<link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
52
nginx-manager-frontend/nginx/nginx.conf
Normal file
52
nginx-manager-frontend/nginx/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
1794
nginx-manager-frontend/package-lock.json
generated
Normal file
1794
nginx-manager-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
nginx-manager-frontend/package.json
Normal file
22
nginx-manager-frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
nginx-manager-frontend/public/favicon.svg
Normal file
1
nginx-manager-frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
nginx-manager-frontend/public/icons.svg
Normal file
24
nginx-manager-frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
59
nginx-manager-frontend/src/App.vue
Normal file
59
nginx-manager-frontend/src/App.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Nginx域名转发管理平台 - 根组件
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* 安全区域适配刘海屏 */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* ── 移动端全局优化 ── */
|
||||
@media (max-width: 768px) {
|
||||
html { font-size: 14px; }
|
||||
|
||||
/* 对话框触摸友好 */
|
||||
.el-dialog__headerbtn {
|
||||
width: 40px; height: 40px;
|
||||
}
|
||||
.el-dialog__headerbtn .el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 按钮增大触摸区域 */
|
||||
.el-button--small { min-height: 32px; }
|
||||
|
||||
/* 分页器缩小间距 */
|
||||
.el-pagination { justify-content: center; flex-wrap: wrap; }
|
||||
.el-pagination button, .el-pagination .el-pager li {
|
||||
min-width: 30px; height: 30px; line-height: 30px;
|
||||
}
|
||||
|
||||
/* 表格在移动端被卡片替代,不再需要 */
|
||||
.el-table { font-size: 13px; }
|
||||
|
||||
/* 消息提示缩小 */
|
||||
.el-message { min-width: auto; max-width: 90vw; padding: 10px 16px; }
|
||||
|
||||
/* 确认框 */
|
||||
.el-message-box { width: 90vw !important; }
|
||||
|
||||
/* 输入框/选择框不缩放 */
|
||||
.el-input__inner, .el-select .el-input__inner { font-size: 16px; }
|
||||
|
||||
/* 标签缩小 */
|
||||
.el-tag { font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
134
nginx-manager-frontend/src/api/nginxApi.js
Normal file
134
nginx-manager-frontend/src/api/nginxApi.js
Normal file
@@ -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)
|
||||
}
|
||||
BIN
nginx-manager-frontend/src/assets/hero.png
Normal file
BIN
nginx-manager-frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
nginx-manager-frontend/src/assets/vite.svg
Normal file
1
nginx-manager-frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
27
nginx-manager-frontend/src/composables/useMobile.js
Normal file
27
nginx-manager-frontend/src/composables/useMobile.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 移动端检测 composable
|
||||
* 统一管理响应式断点判断,避免各组件重复手写
|
||||
*
|
||||
* @param {number} breakpoint - 断点宽度,默认 768px
|
||||
* @returns {{ isMobile: import('vue').Ref<boolean> }}
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
196
nginx-manager-frontend/src/layout/MainLayout.vue
Normal file
196
nginx-manager-frontend/src/layout/MainLayout.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<!-- 移动端遮罩 -->
|
||||
<div
|
||||
class="sidebar-overlay"
|
||||
:class="{ 'is-visible': sidebarOpen }"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 左侧菜单 -->
|
||||
<el-aside
|
||||
width="220px"
|
||||
class="layout-aside"
|
||||
:class="{ 'is-open': sidebarOpen }"
|
||||
@touchstart="onTouchStart"
|
||||
@touchend="onTouchEnd"
|
||||
>
|
||||
<div class="logo">
|
||||
<h2>Nginx 管理</h2>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
router
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
@select="onMenuSelect"
|
||||
>
|
||||
<!-- 直接用网关菜单树渲染(支持目录/子菜单层级) -->
|
||||
<template v-for="item in menuTree" :key="item.id || item.path">
|
||||
<!-- 有子节点 → el-sub-menu -->
|
||||
<el-sub-menu
|
||||
v-if="item.component === 'Layout' && item.children && item.children.length"
|
||||
:index="item.path"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon v-if="item.meta?.icon || item.icon">
|
||||
<component :is="resolveIcon(item.meta?.icon || item.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ item.name }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.id || child.path"
|
||||
:index="child.path"
|
||||
>
|
||||
<el-icon v-if="child.meta?.icon || child.icon">
|
||||
<component :is="resolveIcon(child.meta?.icon || child.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ child.name }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<!-- 无子节点 → 直接 el-menu-item -->
|
||||
<el-menu-item v-else :index="item.path">
|
||||
<el-icon v-if="item.meta?.icon || item.icon">
|
||||
<component :is="resolveIcon(item.meta?.icon || item.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ item.name }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<el-container>
|
||||
<el-header class="layout-header">
|
||||
<div class="header-left">
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="hamburger"
|
||||
:class="{ 'is-active': sidebarOpen }"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
>
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
<span class="header-title">Nginx域名转发管理平台</span>
|
||||
</div>
|
||||
<!-- Mokee UserChip Widget 占位 -->
|
||||
<div
|
||||
id="mokee-user-chip"
|
||||
data-api-base="https://gw.server.zgitm.com"
|
||||
data-cookie-domain=".zgitm.com"
|
||||
/>
|
||||
</el-header>
|
||||
|
||||
<el-main class="layout-main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { menuTree } from '../router'
|
||||
import {
|
||||
Connection, Key, Link,
|
||||
Setting, Document, User, Avatar,
|
||||
Tickets, Menu, Share, Star,
|
||||
Picture, List, DataAnalysis, Monitor
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const route = useRoute()
|
||||
const activeMenu = computed(() => route.path)
|
||||
const sidebarOpen = ref(false)
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// 图标名称 → 组件映射
|
||||
const ICON_MAP = {
|
||||
Connection, Key, Link,
|
||||
Setting, Document, User, Avatar,
|
||||
Tickets, Menu, Share, Star,
|
||||
Picture, List, DataAnalysis, Monitor,
|
||||
}
|
||||
|
||||
function resolveIcon(name) {
|
||||
return ICON_MAP[name] || Menu
|
||||
}
|
||||
|
||||
function onMenuSelect() {
|
||||
if (isMobile.value) sidebarOpen.value = false
|
||||
}
|
||||
|
||||
let touchStartX = 0
|
||||
function onTouchStart(e) { touchStartX = e.touches[0].clientX }
|
||||
function onTouchEnd(e) {
|
||||
if (e.changedTouches[0].clientX - touchStartX < -60 && sidebarOpen.value) {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container { height: 100vh; }
|
||||
|
||||
.layout-aside {
|
||||
background-color: #304156;
|
||||
overflow-y: auto;
|
||||
display: flex; flex-direction: column;
|
||||
transition: transform 0.25s ease; z-index: 1000;
|
||||
}
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background-color: #2b3a4a; flex-shrink: 0;
|
||||
}
|
||||
.logo h2 { color: #fff; font-size: 18px; margin: 0; letter-spacing: 2px; }
|
||||
.el-menu { border-right: none; flex: 1; }
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4); z-index: 999;
|
||||
}
|
||||
.sidebar-overlay.is-visible { display: block; }
|
||||
|
||||
.layout-header {
|
||||
background: #fff;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
padding: 0 16px; height: 50px; flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 8px; }
|
||||
.header-title { font-size: 15px; color: #303133; font-weight: 500; }
|
||||
#mokee-user-chip { flex-shrink: 0; }
|
||||
|
||||
.layout-main { background: #f0f2f5; padding: 16px; overflow-y: auto; }
|
||||
|
||||
.hamburger {
|
||||
display: none; width: 24px; height: 18px; position: relative;
|
||||
background: none; border: none; cursor: pointer;
|
||||
padding: 0; margin-right: 10px; flex-shrink: 0;
|
||||
}
|
||||
.hamburger span {
|
||||
display: block; position: absolute; height: 2px; width: 100%;
|
||||
background: #303133; border-radius: 2px; transition: all 0.25s ease;
|
||||
}
|
||||
.hamburger span:nth-child(1) { top: 0; }
|
||||
.hamburger span:nth-child(2) { top: 8px; }
|
||||
.hamburger span:nth-child(3) { top: 16px; }
|
||||
.hamburger.is-active span:nth-child(1) { top: 8px; transform: rotate(45deg); }
|
||||
.hamburger.is-active span:nth-child(2) { opacity: 0; }
|
||||
.hamburger.is-active span:nth-child(3) { top: 8px; transform: rotate(-45deg); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hamburger { display: block; }
|
||||
.layout-aside {
|
||||
position: fixed; top: 0; left: 0; height: 100vh;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.layout-aside.is-open { transform: translateX(0); }
|
||||
.header-title { font-size: 14px; }
|
||||
.layout-main { padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
68
nginx-manager-frontend/src/main.js
Normal file
68
nginx-manager-frontend/src/main.js
Normal file
@@ -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 = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;">
|
||||
<div style="text-align:center;color:#F56C6C;">
|
||||
<h2>⚠️ 菜单加载失败</h2>
|
||||
<p style="color:#909399;">无法从网关获取系统菜单,请稍后重试。</p>
|
||||
<p style="color:#C0C4CC;font-size:13px;">${e.message}</p>
|
||||
</div>
|
||||
</div>`
|
||||
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()
|
||||
96
nginx-manager-frontend/src/router/index.js
Normal file
96
nginx-manager-frontend/src/router/index.js
Normal file
@@ -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
|
||||
61
nginx-manager-frontend/src/utils/request.js
Normal file
61
nginx-manager-frontend/src/utils/request.js
Normal file
@@ -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
|
||||
283
nginx-manager-frontend/src/views/CertManagement.vue
Normal file
283
nginx-manager-frontend/src/views/CertManagement.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="cert-management-container">
|
||||
<!-- 顶部标题 -->
|
||||
<div class="page-header">
|
||||
<h1>SSL证书管理</h1>
|
||||
<p class="subtitle">管理 Let's Encrypt 域名证书与到期邮件通知</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button @click="fetchCerts" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon> 刷新列表
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端:表格视图 -->
|
||||
<div v-if="!isMobile" class="table-container">
|
||||
<el-table :data="certs" v-loading="loading" stripe border style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="domain" label="域名" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="domain-text">{{ row.domain }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="certType" label="证书类型" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.certType === 'letsencrypt' ? 'success' : 'info'" effect="light">
|
||||
{{ row.certType === 'letsencrypt' ? 'Let\'s Encrypt' : '内部CA' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expireDate" label="到期时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.expireDate">{{ formatDate(row.expireDate) }}</span>
|
||||
<el-tag v-else type="info" size="small">未签发</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余天数" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.daysLeft !== undefined && row.daysLeft !== null"
|
||||
:style="{ color: row.daysLeft <= 15 ? '#F56C6C' : row.daysLeft <= 30 ? '#E6A23C' : '#67C23A' }"
|
||||
class="days-text">
|
||||
{{ row.daysLeft }} 天
|
||||
</span>
|
||||
<span v-else style="color: #C0C4CC">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" effect="plain" size="small">
|
||||
{{ row.status === 'active' ? '正常' : '异常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="邮件通知" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.notifyEnabled && row.notifyEmail">
|
||||
<el-tooltip :content="'收件人: ' + row.notifyEmail" placement="top">
|
||||
<el-tag type="primary" effect="plain" size="small">
|
||||
<el-icon style="margin-right:4px"><Message /></el-icon>
|
||||
{{ row.notifyDaysBefore }}天前提醒
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else style="color: #C0C4CC">未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="warning" size="small" @click="showNotifyDialog(row)"
|
||||
v-if="row.certType === 'letsencrypt'">
|
||||
<el-icon><Edit /></el-icon> 通知设置
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端:卡片视图 -->
|
||||
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
|
||||
<div v-if="certs.length === 0" class="mobile-empty">暂无证书</div>
|
||||
<div v-for="row in certs" :key="row.id" class="mobile-card">
|
||||
<div class="card-top">
|
||||
<span class="card-domain">{{ row.domain }}</span>
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small" effect="dark">
|
||||
{{ row.status === 'active' ? '正常' : '异常' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-row">
|
||||
<span class="card-label">类型</span>
|
||||
<el-tag :type="row.certType === 'letsencrypt' ? 'success' : 'info'" size="small" effect="plain">
|
||||
{{ row.certType === 'letsencrypt' ? 'Let\'s Encrypt' : '内部CA' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">到期</span>
|
||||
<span v-if="row.expireDate" :style="{ color: row.daysLeft <= 15 ? '#F56C6C' : row.daysLeft <= 30 ? '#E6A23C' : '#67C23A' }"
|
||||
style="font-weight:bold;font-size:14px;">
|
||||
{{ formatDate(row.expireDate) }} · 剩{{ row.daysLeft }}天
|
||||
</span>
|
||||
<span v-else style="color:#C0C4CC">未签发</span>
|
||||
</div>
|
||||
<div v-if="row.notifyEnabled && row.notifyEmail" class="card-row">
|
||||
<span class="card-label">通知</span>
|
||||
<span style="font-size:13px;color:#606266;">
|
||||
📧 {{ row.notifyEmail }} · 提前{{ row.notifyDaysBefore }}天
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions" v-if="row.certType === 'letsencrypt'">
|
||||
<el-button type="warning" size="small" @click="showNotifyDialog(row)">
|
||||
通知设置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知设置对话框 -->
|
||||
<el-dialog v-model="notifyDialogVisible" title="通知设置" width="480px" destroy-on-close>
|
||||
<el-form :model="notifyForm" label-width="110px">
|
||||
<el-form-item label="域名">
|
||||
<el-input :model-value="notifyTarget?.domain" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="收件人邮箱" prop="notifyEmail">
|
||||
<el-input v-model="notifyForm.notifyEmail" placeholder="例如:admin@mokee.com" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用通知">
|
||||
<el-switch v-model="notifyForm.notifyEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提前通知天数">
|
||||
<el-input-number v-model="notifyForm.notifyDaysBefore" :min="1" :max="30" :step="1" />
|
||||
<span style="margin-left:8px;color:#909399;font-size:12px;">到期前开始连续提醒</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="notifyDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveNotifyConfig" :loading="savingNotify">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Edit, Message } from '@element-plus/icons-vue'
|
||||
import { getCertificates, updateCertNotifyConfig } from '../api/nginxApi'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const loading = ref(false)
|
||||
const certs = ref([])
|
||||
|
||||
// 通知设置对话框
|
||||
const notifyDialogVisible = ref(false)
|
||||
const savingNotify = ref(false)
|
||||
const notifyTarget = ref(null)
|
||||
const notifyForm = reactive({
|
||||
notifyEmail: '',
|
||||
notifyEnabled: true,
|
||||
notifyDaysBefore: 15
|
||||
})
|
||||
|
||||
/** 格式化日期 */
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return dateStr.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
/** 获取证书列表 */
|
||||
async function fetchCerts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getCertificates()
|
||||
const data = res.data || []
|
||||
// 计算剩余天数
|
||||
certs.value = data.map(cert => ({
|
||||
...cert,
|
||||
daysLeft: cert.expireDate
|
||||
? Math.ceil((new Date(cert.expireDate) - new Date()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
}))
|
||||
} catch (e) {
|
||||
ElMessage.error('获取证书列表失败:' + e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示通知设置对话框 */
|
||||
function showNotifyDialog(row) {
|
||||
notifyTarget.value = row
|
||||
notifyForm.notifyEmail = row.notifyEmail || 'zg@zgitm.com'
|
||||
notifyForm.notifyEnabled = row.notifyEnabled === 1
|
||||
notifyForm.notifyDaysBefore = row.notifyDaysBefore || 15
|
||||
notifyDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存通知配置 */
|
||||
async function saveNotifyConfig() {
|
||||
savingNotify.value = true
|
||||
try {
|
||||
await updateCertNotifyConfig(notifyTarget.value.id, {
|
||||
notifyEmail: notifyForm.notifyEmail,
|
||||
notifyEnabled: notifyForm.notifyEnabled ? 1 : 0,
|
||||
notifyDaysBefore: notifyForm.notifyDaysBefore
|
||||
})
|
||||
ElMessage.success('通知配置已更新')
|
||||
notifyDialogVisible.value = false
|
||||
await fetchCerts()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败:' + e.message)
|
||||
} finally {
|
||||
savingNotify.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCerts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cert-management-container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 24px; color: #303133; }
|
||||
.page-header .subtitle { margin: 4px 0 0; color: #909399; font-size: 14px; }
|
||||
.toolbar { margin-bottom: 16px; }
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.domain-text { font-weight: 500; color: #303133; }
|
||||
.days-text { font-weight: bold; font-size: 15px; }
|
||||
|
||||
/* ════════════════════════════════════════════════ */
|
||||
/* 移动端 (≤768px) */
|
||||
/* ════════════════════════════════════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.cert-management-container { padding: 8px; }
|
||||
.page-header h1 { font-size: 18px; }
|
||||
.page-header .subtitle { font-size: 12px; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; }
|
||||
|
||||
/* 隐藏桌面表格 */
|
||||
.table-container { display: none; }
|
||||
|
||||
/* 移动端卡片列表 */
|
||||
.mobile-card-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mobile-empty { text-align: center; color: #C0C4CC; padding: 40px 0; font-size: 14px; }
|
||||
|
||||
.mobile-card {
|
||||
background: #fff; border-radius: 10px; padding: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.card-top {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding-bottom: 10px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.card-domain { font-size: 15px; font-weight: 600; color: #303133; flex:1; word-break: break-all; }
|
||||
.card-body { padding: 10px 0; }
|
||||
.card-row { display: flex; align-items: center; margin-bottom: 6px; gap: 8px; }
|
||||
.card-label { color: #909399; font-size: 12px; width: 36px; flex-shrink: 0; }
|
||||
|
||||
.card-actions {
|
||||
display: flex; gap: 8px; padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0; justify-content: flex-end; flex-wrap: wrap;
|
||||
}
|
||||
.card-actions .el-button { min-height: 36px; }
|
||||
|
||||
:deep(.el-dialog) { width: 95% !important; margin: 2vh auto !important; }
|
||||
:deep(.el-dialog__body) { padding: 12px 8px; }
|
||||
:deep(.el-form-item__label) { width: 80px !important; font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
350
nginx-manager-frontend/src/views/DnsManagement.vue
Normal file
350
nginx-manager-frontend/src/views/DnsManagement.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="dns-management-container">
|
||||
<div class="page-header">
|
||||
<h1>DNS 解析管理</h1>
|
||||
<p class="subtitle">管理 zgitm.com 子域名解析记录,自动同步至阿里云 DNS</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-tag type="success" size="large" effect="dark">
|
||||
主域名: {{ zoneInfo.zone }}
|
||||
</el-tag>
|
||||
<el-tag type="info" size="large" style="margin-left:10px">
|
||||
默认IP: {{ zoneInfo.defaultIp }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button type="primary" @click="showAddDialog">
|
||||
<el-icon><Plus /></el-icon> 添加子域名
|
||||
</el-button>
|
||||
<el-button @click="fetchRecords" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端:表格视图 -->
|
||||
<div v-if="!isMobile" class="table-container">
|
||||
<el-table :data="records" v-loading="loading" stripe border style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="full_domain" label="完整域名" min-width="220" />
|
||||
<el-table-column prop="type" label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'A' ? '' : 'warning'" size="small">{{ row.type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="value" label="记录值" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<code class="record-value">{{ row.value }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ttl" label="TTL" width="80" align="center" />
|
||||
<el-table-column prop="createdBy" label="创建人" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.createdBy || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon> 修改
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端:卡片视图 -->
|
||||
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
|
||||
<div v-if="records.length === 0" class="mobile-empty">暂无 DNS 记录</div>
|
||||
<div v-for="row in records" :key="row.record_id || row.full_domain" class="mobile-card">
|
||||
<div class="card-top">
|
||||
<el-tag :type="row.type === 'A' ? '' : 'warning'" size="small" effect="dark">{{ row.type }}</el-tag>
|
||||
<span class="card-domain">{{ row.full_domain }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-row">
|
||||
<span class="card-label">记录值</span>
|
||||
<code class="card-value" style="color:#409EFF;background:#ecf5ff;">{{ row.value }}</code>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">TTL</span>
|
||||
<span style="font-size:13px;color:#606266;">{{ row.ttl }}s</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">创建人</span>
|
||||
<span style="font-size:13px;color:#606266;">{{ row.createdBy || '-' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">创建时间</span>
|
||||
<span style="font-size:12px;color:#909399;">{{ formatDate(row.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" size="small" @click="showEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon> 修改
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/修改对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEditing ? '修改DNS记录' : '添加子域名'"
|
||||
width="480px" destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" label-width="90px">
|
||||
<el-form-item label="主域名">
|
||||
<el-input :model-value="zoneInfo.zone" disabled>
|
||||
<template #prefix>. </template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="主机记录" prop="rr">
|
||||
<el-input v-model="formData.rr" placeholder="例如:webssd 或 api.test">
|
||||
<template #append>.{{ zoneInfo.zone }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="记录类型">
|
||||
<el-select v-model="formData.type" style="width:100%">
|
||||
<el-option label="A (IP地址)" value="A" />
|
||||
<el-option label="CNAME (域名)" value="CNAME" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="记录值" prop="value">
|
||||
<el-input v-model="formData.value"
|
||||
:placeholder="formData.type === 'A' ? '例如:10.1.1.160' : '例如:other.zgitm.com'" />
|
||||
</el-form-item>
|
||||
<el-form-item label="TTL">
|
||||
<el-select v-model="formData.ttl" style="width:100%">
|
||||
<el-option label="600 (10分钟)" :value="600" />
|
||||
<el-option label="1800 (30分钟)" :value="1800" />
|
||||
<el-option label="3600 (1小时)" :value="3600" />
|
||||
<el-option label="86400 (1天)" :value="86400" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitting">
|
||||
{{ isEditing ? '确认修改' : '确认添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { getDnsDefaultZone, getDnsRecords, addDnsRecord, deleteDnsRecord, updateDnsRecord } from '../api/nginxApi'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const zoneInfo = reactive({ zone: 'zgitm.com', defaultIp: '10.1.1.160' })
|
||||
const records = ref([])
|
||||
const formRef = ref(null)
|
||||
const formData = reactive({ rr: '', type: 'A', value: '', ttl: 600 })
|
||||
|
||||
/** 格式化日期为本地字符串 */
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取 DNS 记录 */
|
||||
async function fetchRecords() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDnsRecords(zoneInfo.zone)
|
||||
records.value = (res.data?.data || res.data || [])
|
||||
} catch (e) {
|
||||
ElMessage.error('获取DNS记录失败:' + e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载默认配置 */
|
||||
async function loadZoneConfig() {
|
||||
try {
|
||||
const res = await getDnsDefaultZone()
|
||||
if (res.data) {
|
||||
zoneInfo.zone = res.data.zone || 'zgitm.com'
|
||||
zoneInfo.defaultIp = res.data.defaultIp || '10.1.1.160'
|
||||
}
|
||||
} catch (e) {
|
||||
// 用默认值
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示添加对话框 */
|
||||
function showAddDialog() {
|
||||
isEditing.value = false
|
||||
editingId.value = null
|
||||
formData.rr = ''
|
||||
formData.type = 'A'
|
||||
formData.value = zoneInfo.defaultIp
|
||||
formData.ttl = 600
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 显示修改对话框 */
|
||||
function showEditDialog(row) {
|
||||
isEditing.value = true
|
||||
editingId.value = row.record_id
|
||||
formData.rr = row.rr
|
||||
formData.type = row.type
|
||||
formData.value = row.value
|
||||
formData.ttl = row.ttl
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 提交 */
|
||||
async function submitForm() {
|
||||
if (!formData.rr.trim()) {
|
||||
ElMessage.warning('请输入主机记录')
|
||||
return
|
||||
}
|
||||
if (!formData.value.trim()) {
|
||||
ElMessage.warning('请输入记录值')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const body = {
|
||||
zone: zoneInfo.zone,
|
||||
rr: formData.rr.trim(),
|
||||
type: formData.type,
|
||||
value: formData.value.trim(),
|
||||
ttl: formData.ttl
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
await updateDnsRecord(editingId.value, body)
|
||||
ElMessage.success('DNS记录已更新')
|
||||
} else {
|
||||
await addDnsRecord(body)
|
||||
ElMessage.success('DNS记录已添加,阿里云同步生效')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchRecords()
|
||||
} catch (e) {
|
||||
ElMessage.error((isEditing.value ? '修改' : '添加') + '失败:' + e.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除 */
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除 <strong>${row.full_domain}</strong> 的 ${row.type} 记录吗?`,
|
||||
'确认删除', { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning', dangerouslyUseHTMLString: true }
|
||||
).then(async () => {
|
||||
try {
|
||||
await deleteDnsRecord(row.record_id)
|
||||
ElMessage.success('DNS记录已删除')
|
||||
await fetchRecords()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败:' + e.message)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadZoneConfig()
|
||||
await fetchRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dns-management-container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 24px; color: #303133; }
|
||||
.page-header .subtitle { margin: 4px 0 0; color: #909399; font-size: 14px; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.record-value { background: #f5f7fa; padding: 2px 8px; border-radius: 4px; font-size: 13px; color: #409EFF; }
|
||||
|
||||
/* ════════════════════════════════════════════════ */
|
||||
/* 移动端 (≤768px) */
|
||||
/* ════════════════════════════════════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.dns-management-container { padding: 8px; }
|
||||
.page-header h1 { font-size: 18px; }
|
||||
.page-header .subtitle { font-size: 12px; }
|
||||
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.toolbar-left, .toolbar-right { width: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
/* 隐藏桌面表格 */
|
||||
.table-container { display: none; }
|
||||
|
||||
/* 移动端卡片列表 */
|
||||
.mobile-card-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mobile-empty { text-align: center; color: #C0C4CC; padding: 40px 0; font-size: 14px; }
|
||||
|
||||
.mobile-card {
|
||||
background: #fff; border-radius: 10px; padding: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.card-top {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding-bottom: 10px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.card-domain { font-size: 15px; font-weight: 600; color: #303133; flex:1; word-break: break-all; }
|
||||
.card-body { padding: 10px 0; }
|
||||
.card-row { display: flex; align-items: center; margin-bottom: 6px; gap: 8px; }
|
||||
.card-label { color: #909399; font-size: 12px; width: 48px; flex-shrink: 0; }
|
||||
.card-value { font-size: 13px; padding: 2px 6px; border-radius: 4px; word-break: break-all; }
|
||||
|
||||
.card-actions {
|
||||
display: flex; gap: 8px; padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0; justify-content: flex-end;
|
||||
}
|
||||
.card-actions .el-button { min-height: 36px; }
|
||||
|
||||
:deep(.el-dialog) { width: 95% !important; margin: 2vh auto !important; }
|
||||
:deep(.el-dialog__body) { padding: 12px 8px; }
|
||||
:deep(.el-form-item__label) { width: 75px !important; font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
400
nginx-manager-frontend/src/views/NginxForwarding.vue
Normal file
400
nginx-manager-frontend/src/views/NginxForwarding.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="nginx-forwarding-container">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="page-header">
|
||||
<h1>Nginx转发管理</h1>
|
||||
<p class="subtitle">管理 Nginx 反向代理规则与 HTTPS 证书</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作区域 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-tag type="warning" size="large">
|
||||
<el-icon style="margin-right: 4px"><InfoFilled /></el-icon>
|
||||
新增/修改/删除请在宝塔面板上操作
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索域名..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
<el-select v-model="protocolFilter" placeholder="协议筛选" clearable style="width: 120px; margin-left: 10px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="HTTP" value="http" />
|
||||
<el-option label="HTTPS" value="https" />
|
||||
</el-select>
|
||||
<el-button @click="fetchRules" :loading="loading" style="margin-left: 10px">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端:表格视图 -->
|
||||
<div v-if="!isMobile" class="table-container">
|
||||
<el-table
|
||||
:data="filteredRules"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
empty-text="暂无转发规则,点击 [新增规则] 添加"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="domain" label="域名" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="domain-text">{{ row.domain }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="protocol" label="协议类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.protocol === 'https' ? 'success' : 'info'" effect="light">
|
||||
{{ row.protocol.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target" label="转发目标地址" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<code class="target-address">{{ row.target }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sslCert" label="SSL证书" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.sslCert">
|
||||
<el-tooltip :content="row.sslCert" placement="top">
|
||||
<el-tag type="warning" effect="plain" size="small">已配置</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="color: #c0c4cc">-</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdBy" label="创建人" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.createdBy || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="isSystemDomain(row.domain)">
|
||||
<el-tag type="info" size="small">系统域名</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" link size="small" @click="showDetailDialog(row)">
|
||||
<el-icon><View /></el-icon> 查看
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端:卡片视图 -->
|
||||
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
|
||||
<div v-if="filteredRules.length === 0" class="mobile-empty">暂无转发规则</div>
|
||||
<div v-for="row in filteredRules" :key="row.id" class="mobile-card">
|
||||
<div class="card-top">
|
||||
<el-tag :type="row.protocol === 'https' ? 'success' : 'info'" size="small" effect="dark">
|
||||
{{ row.protocol.toUpperCase() }}
|
||||
</el-tag>
|
||||
<span class="card-domain">{{ row.domain }}</span>
|
||||
<el-tag v-if="row.sslCert" type="warning" size="small" effect="plain">SSL</el-tag>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-row">
|
||||
<span class="card-label">转发目标</span>
|
||||
<code class="card-value">{{ row.target }}</code>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">创建人</span>
|
||||
<span style="font-size:13px;color:#606266;">{{ row.createdBy || '-' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">创建时间</span>
|
||||
<span style="font-size:12px;color:#909399;">{{ formatDate(row.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<template v-if="!isSystemDomain(row.domain)">
|
||||
<el-button type="primary" size="small" @click="showDetailDialog(row)">
|
||||
<el-icon><View /></el-icon> 查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情查看对话框(只读) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="'转发规则详情 — ' + detailData.domain"
|
||||
width="900px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="detailLoading" style="min-height: 300px">
|
||||
<!-- 基本信息 -->
|
||||
<el-descriptions :column="2" border size="default" label-width="100px" style="margin-bottom: 16px">
|
||||
<el-descriptions-item label="域名">
|
||||
<span class="detail-domain">{{ detailData.domain }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="协议类型">
|
||||
<el-tag :type="detailData.protocol === 'https' ? 'success' : 'info'" effect="light">
|
||||
{{ detailData.protocol?.toUpperCase() }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="转发目标" :span="2">
|
||||
<code class="target-address">{{ detailData.target }}</code>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- SSL证书信息 -->
|
||||
<template v-if="detailData.protocol === 'https' || detailData.sslStatus">
|
||||
<el-divider content-position="left">
|
||||
<el-icon><Lock /></el-icon> SSL 证书
|
||||
</el-divider>
|
||||
<el-descriptions :column="2" border size="small" style="margin-bottom: 16px">
|
||||
<el-descriptions-item label="证书状态">
|
||||
<el-tag v-if="detailData.sslStatus === 'valid'" type="success" size="small">有效</el-tag>
|
||||
<el-tag v-else-if="detailData.sslStatus === 'none'" type="info" size="small">未配置</el-tag>
|
||||
<el-tag v-else-if="detailData.sslStatus === 'error'" type="danger" size="small">获取失败</el-tag>
|
||||
<el-tag v-else type="warning" size="small">{{ detailData.sslStatus || '未知' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="到期时间">
|
||||
<span v-if="detailData.sslExpireDate">{{ formatDate(detailData.sslExpireDate) }}</span>
|
||||
<span v-else style="color:#C0C4CC">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="剩余天数" v-if="detailData.sslDaysLeft !== undefined">
|
||||
<span :style="{ color: detailData.sslDaysLeft <= 15 ? '#F56C6C' : detailData.sslDaysLeft <= 30 ? '#E6A23C' : '#67C23A', fontWeight: 'bold' }">
|
||||
{{ detailData.sslDaysLeft }} 天
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="验证方式" v-if="detailData.sslAuthType">
|
||||
<el-tag size="small" effect="plain">{{ detailData.sslAuthType?.toUpperCase() }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
|
||||
<!-- Nginx 配置文件内容 -->
|
||||
<el-divider content-position="left">
|
||||
<el-icon><Document /></el-icon> 配置文件
|
||||
</el-divider>
|
||||
<el-input
|
||||
:model-value="detailData.configContent || '加载中...'"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
readonly
|
||||
style="font-family: 'Consolas', 'Courier New', monospace; font-size: 13px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, View, InfoFilled, Lock, Document } from '@element-plus/icons-vue'
|
||||
import { getRules, getRuleConfig } from '../api/nginxApi'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// ─── 状态 ───────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
const rules = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const protocolFilter = ref('')
|
||||
|
||||
const detailData = reactive({
|
||||
domain: '',
|
||||
protocol: '',
|
||||
target: '',
|
||||
configContent: '',
|
||||
sslStatus: '',
|
||||
sslExpireDate: '',
|
||||
sslDaysLeft: null,
|
||||
sslAuthType: ''
|
||||
})
|
||||
|
||||
// ─── 域名判断 ────────────────────────────────
|
||||
const SYSTEM_DOMAINS = ['zgitm.com', 'www.zgitm.com']
|
||||
function isSystemDomain(domain) {
|
||||
return SYSTEM_DOMAINS.includes(domain?.toLowerCase())
|
||||
}
|
||||
|
||||
/** 格式化日期为本地字符串 */
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
// 处理 ISO 格式或时间戳
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return dateStr || '-'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 计算属性 ────────────────────────────────
|
||||
const filteredRules = computed(() => {
|
||||
let result = rules.value
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(r => r.domain.toLowerCase().includes(keyword))
|
||||
}
|
||||
if (protocolFilter.value) {
|
||||
result = result.filter(r => r.protocol === protocolFilter.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ─── 方法 ───────────────────────────────────
|
||||
|
||||
/** 获取规则列表 */
|
||||
async function fetchRules() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRules()
|
||||
rules.value = res.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('获取规则列表失败:' + e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示详情对话框 — 调用后端获取配置文件 + SSL信息 */
|
||||
async function showDetailDialog(row) {
|
||||
dialogVisible.value = true
|
||||
detailLoading.value = true
|
||||
|
||||
// 先填充基本信息
|
||||
detailData.domain = row.domain
|
||||
detailData.protocol = row.protocol
|
||||
detailData.target = row.target
|
||||
detailData.configContent = ''
|
||||
detailData.sslStatus = ''
|
||||
detailData.sslExpireDate = ''
|
||||
detailData.sslDaysLeft = null
|
||||
detailData.sslAuthType = ''
|
||||
|
||||
try {
|
||||
const res = await getRuleConfig(row.id)
|
||||
const data = res.data?.data || res.data
|
||||
if (data) {
|
||||
detailData.configContent = data.configContent || ''
|
||||
detailData.sslStatus = data.sslStatus || ''
|
||||
detailData.sslExpireDate = data.sslExpireDate || ''
|
||||
detailData.sslDaysLeft = data.sslDaysLeft
|
||||
detailData.sslAuthType = data.sslAuthType || ''
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.warning('获取详情失败:' + e.message)
|
||||
detailData.configContent = '获取配置文件失败'
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 生命周期 ────────────────────────────────
|
||||
onMounted(() => {
|
||||
fetchRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nginx-forwarding-container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 24px; color: #303133; }
|
||||
.page-header .subtitle { margin: 4px 0 0; color: #909399; font-size: 14px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.domain-text { font-weight: 500; color: #303133; }
|
||||
.target-address { background: #f5f7fa; padding: 2px 8px; border-radius: 4px; font-size: 13px; color: #e6a23c; }
|
||||
|
||||
.detail-domain { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
|
||||
/* ════════════════════════════════════════════════ */
|
||||
/* 移动端 (≤768px) */
|
||||
/* ════════════════════════════════════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.nginx-forwarding-container { padding: 8px; }
|
||||
.page-header h1 { font-size: 18px; }
|
||||
.page-header .subtitle { font-size: 12px; }
|
||||
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.toolbar-left, .toolbar-right { width: 100%; }
|
||||
.toolbar-right :deep(.el-input) { width: 100% !important; }
|
||||
.toolbar-right :deep(.el-select) { width: 100% !important; margin-left: 0 !important; }
|
||||
|
||||
/* 隐藏桌面表格,改用卡片 */
|
||||
.table-container { display: none; }
|
||||
|
||||
/* 移动端卡片列表 */
|
||||
.mobile-card-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mobile-empty { text-align: center; color: #C0C4CC; padding: 40px 0; font-size: 14px; }
|
||||
|
||||
.mobile-card {
|
||||
background: #fff; border-radius: 10px; padding: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.card-top {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding-bottom: 10px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.card-domain { font-size: 15px; font-weight: 600; color: #303133; flex:1; word-break: break-all; }
|
||||
.card-body { padding: 10px 0; }
|
||||
.card-row { display: flex; align-items: flex-start; margin-bottom: 6px; }
|
||||
.card-label { color: #909399; font-size: 12px; width: 56px; flex-shrink: 0; padding-top: 1px; }
|
||||
.card-value { font-size: 13px; color: #E6A23C; background: #fdf6ec; padding: 2px 6px; border-radius: 4px; word-break: break-all; }
|
||||
|
||||
.card-actions {
|
||||
display: flex; gap: 8px; padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0; justify-content: flex-end;
|
||||
}
|
||||
.card-actions .el-button { min-height: 36px; min-width: 64px; }
|
||||
|
||||
/* 对话框全屏 */
|
||||
:deep(.el-dialog) { width: 95% !important; margin: 2vh auto !important; }
|
||||
:deep(.el-dialog__body) { padding: 12px 8px; }
|
||||
:deep(.el-form-item__label) { width: 70px !important; font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
9
nginx-manager-frontend/vite.config.js
Normal file
9
nginx-manager-frontend/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 31002
|
||||
}
|
||||
})
|
||||
619
nginx-python-api/app.py
Normal file
619
nginx-python-api/app.py
Normal file
@@ -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/<domain>', 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/<domain>', 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/<domain>', 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/<domain>/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/<domain>/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/<domain>/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/<domain>/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/<domain>/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/<domain>/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/<domain>', 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/<domain>/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/<domain>/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/<record_id>', 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/<record_id>', 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)
|
||||
70
nginx-python-api/deploy.sh
Normal file
70
nginx-python-api/deploy.sh
Normal file
@@ -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 "========================================="
|
||||
29
nginx-python-api/gunicorn_config.py
Normal file
29
nginx-python-api/gunicorn_config.py
Normal file
@@ -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
|
||||
2
nginx-python-api/requirements.txt
Normal file
2
nginx-python-api/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask==3.1.0
|
||||
gunicorn==23.0.0
|
||||
0
nginx-python-api/services/__init__.py
Normal file
0
nginx-python-api/services/__init__.py
Normal file
263
nginx-python-api/services/cert_notifier.py
Normal file
263
nginx-python-api/services/cert_notifier.py
Normal file
@@ -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"""<!DOCTYPE html>
|
||||
<html><body style="font-family: 'Microsoft YaHei', Arial, sans-serif;">
|
||||
<div style="max-width:600px;margin:0 auto;padding:20px;border:1px solid #EBEEF5;border-radius:8px;">
|
||||
<div style="background:{color};padding:16px;border-radius:8px 8px 0 0;text-align:center;">
|
||||
<h2 style="color:#fff;margin:0;">⚠️ SSL 证书即将到期</h2>
|
||||
</div>
|
||||
<div style="padding:20px;background:#fff;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:8px 0;color:#909399;width:80px;">域名</td>
|
||||
<td style="padding:8px 0;font-weight:bold;font-size:16px;">{domain}</td></tr>
|
||||
<tr><td style="padding:8px 0;color:#909399;">到期时间</td>
|
||||
<td style="padding:8px 0;">{expire_date}</td></tr>
|
||||
<tr><td style="padding:8px 0;color:#909399;">剩余天数</td>
|
||||
<td style="padding:8px 0;color:{color};font-weight:bold;font-size:20px;">{days_left} 天</td></tr>
|
||||
<tr><td style="padding:8px 0;color:#909399;">证书类型</td>
|
||||
<td style="padding:8px 0;">Let's Encrypt (阿里云 DNS 验证)</td></tr>
|
||||
</table>
|
||||
<hr style="border:0;border-top:1px solid #EBEEF5;margin:16px 0;">
|
||||
<p style="color:#909399;font-size:13px;">
|
||||
系统已配置 acme.sh 自动续期,正常情况下会在到期前自动更新证书。<br>
|
||||
如续期失败,请检查:<br>
|
||||
1. 阿里云 AccessKey 是否有效<br>
|
||||
2. 服务器能否访问 Let's Encrypt API<br>
|
||||
3. DNS TXT 记录是否正常清理
|
||||
</p>
|
||||
<p style="color:#C0C4CC;font-size:12px;">
|
||||
此邮件由 Nginx 域名管理平台自动发送,连续提醒至证书续期成功。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
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()
|
||||
228
nginx-python-api/services/dns_service.py
Normal file
228
nginx-python-api/services/dns_service.py
Normal file
@@ -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
|
||||
567
nginx-python-api/services/nginx_service.py
Normal file
567
nginx-python-api/services/nginx_service.py
Normal file
@@ -0,0 +1,567 @@
|
||||
"""
|
||||
Nginx 配置管理服务
|
||||
负责读写 Nginx 配置文件、重载 Nginx
|
||||
|
||||
全局优化(在 /etc/nginx/nginx.conf http 块中):
|
||||
- gzip + gzip_types(14种MIME)— 压缩传输
|
||||
- SSL session cache (10m) + timeout (60m) — 减少重复TLS握手
|
||||
- ssl_session_tickets off + ssl_buffer_size 4k
|
||||
|
||||
每个域名配置的优化:
|
||||
- listen 443 ssl http2 — HTTP/2 多路复用
|
||||
- proxy_buffering off — 边收边发,不阻塞
|
||||
- 静态资源 30天强缓存(public, immutable)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Nginx HTTP 配置模板
|
||||
HTTP_TEMPLATE = """# Nginx转发规则 - {domain}
|
||||
# 协议: HTTP
|
||||
# 自动生成,请勿手动修改
|
||||
|
||||
server {{
|
||||
listen 80;
|
||||
server_name {domain};
|
||||
client_max_body_size 2048m;
|
||||
|
||||
# 静态资源缓存(JS/CSS 带 hash,30天)
|
||||
location ~* \\.(js|css|svg|woff2|png|jpg|ico)$ {{
|
||||
expires 30d;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
|
||||
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
|
||||
add_header Cache-Control \"public, immutable\";
|
||||
proxy_pass {target};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection \"\";
|
||||
proxy_set_header Accept-Encoding \"gzip\";
|
||||
proxy_connect_timeout 43200s;
|
||||
proxy_send_timeout 43200s;
|
||||
proxy_read_timeout 43200s;
|
||||
proxy_buffering off;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
|
||||
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
|
||||
proxy_pass {target};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection \"\";
|
||||
proxy_set_header Accept-Encoding \"gzip\";
|
||||
|
||||
proxy_connect_timeout 43200s;
|
||||
proxy_send_timeout 43200s;
|
||||
proxy_read_timeout 43200s;
|
||||
proxy_buffering off;
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
# Nginx HTTPS 配置模板
|
||||
HTTPS_TEMPLATE = """# Nginx转发规则 - {domain}
|
||||
# 协议: HTTPS
|
||||
# 自动生成,请勿手动修改
|
||||
|
||||
# HTTP → HTTPS 重定向
|
||||
server {{
|
||||
listen 80;
|
||||
server_name {domain};
|
||||
return 301 https://$host$request_uri;
|
||||
}}
|
||||
|
||||
# HTTPS 服务
|
||||
server {{
|
||||
listen 443 ssl http2;
|
||||
server_name {domain};
|
||||
|
||||
# CORS 跨域响应头(server 级别,确保 OPTIONS 204 也带 CORS 头)
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
|
||||
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
|
||||
|
||||
# CORS 预检请求快速返回(避免穿透到后端)
|
||||
if ($request_method = OPTIONS) {{ return 204; }}
|
||||
|
||||
ssl_certificate {ssl_cert};
|
||||
ssl_certificate_key {ssl_key};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# 静态资源缓存(JS/CSS 带 hash,30天)
|
||||
location ~* \\.(js|css|svg|woff2|png|jpg|ico)$ {{
|
||||
expires 30d;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
|
||||
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
|
||||
add_header Cache-Control \"public, immutable\";
|
||||
proxy_pass {target};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection \"\";
|
||||
proxy_set_header Accept-Encoding \"gzip\";
|
||||
proxy_connect_timeout 43200s;
|
||||
proxy_send_timeout 43200s;
|
||||
proxy_read_timeout 43200s;
|
||||
proxy_buffering off;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
|
||||
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
|
||||
proxy_pass {target};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection \"\";
|
||||
proxy_set_header Accept-Encoding \"gzip\";
|
||||
|
||||
proxy_connect_timeout 43200s;
|
||||
proxy_send_timeout 43200s;
|
||||
proxy_read_timeout 43200s;
|
||||
proxy_buffering off;
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
class NginxService:
|
||||
"""Nginx 配置管理服务"""
|
||||
|
||||
def __init__(self, conf_dir='/etc/nginx/conf.d', ssl_dir='/etc/nginx/ssl'):
|
||||
self.conf_dir = conf_dir
|
||||
self.ssl_dir = ssl_dir
|
||||
# 确保目录存在
|
||||
os.makedirs(self.conf_dir, exist_ok=True)
|
||||
|
||||
def create_config(self, domain, protocol, target, ssl_cert=None, ssl_key=None):
|
||||
"""
|
||||
创建域名转发配置文件
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
protocol: 协议 (http/https)
|
||||
target: 转发目标地址
|
||||
ssl_cert: SSL证书路径(仅在HTTPS时需要)
|
||||
ssl_key: SSL私钥路径(仅在HTTPS时需要)
|
||||
|
||||
Returns:
|
||||
config_file: 生成的配置文件路径
|
||||
"""
|
||||
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
|
||||
|
||||
if protocol == 'https':
|
||||
content = HTTPS_TEMPLATE.format(
|
||||
domain=domain,
|
||||
target=target,
|
||||
ssl_cert=ssl_cert or f'{self.ssl_dir}/{domain}.pem',
|
||||
ssl_key=ssl_key or f'{self.ssl_dir}/{domain}.key'
|
||||
)
|
||||
else:
|
||||
content = HTTP_TEMPLATE.format(
|
||||
domain=domain,
|
||||
target=target
|
||||
)
|
||||
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info(f"已创建配置文件: {config_path}")
|
||||
return config_path
|
||||
|
||||
def delete_config(self, domain):
|
||||
"""
|
||||
删除域名配置文件
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
bool: 是否成功删除
|
||||
"""
|
||||
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
|
||||
if os.path.exists(config_path):
|
||||
os.remove(config_path)
|
||||
logger.info(f"已删除配置文件: {config_path}")
|
||||
return True
|
||||
logger.warning(f"配置文件不存在: {config_path}")
|
||||
return False
|
||||
|
||||
def list_rules(self):
|
||||
"""
|
||||
解析所有 Nginx 配置文件,提取转发规则列表
|
||||
|
||||
Returns:
|
||||
list[dict]: 规则列表
|
||||
"""
|
||||
rules = []
|
||||
if not os.path.exists(self.conf_dir):
|
||||
return rules
|
||||
|
||||
for filename in sorted(os.listdir(self.conf_dir)):
|
||||
if not filename.endswith('.conf'):
|
||||
continue
|
||||
|
||||
filepath = os.path.join(self.conf_dir, filename)
|
||||
rule = self._parse_config(filepath)
|
||||
if rule:
|
||||
rules.append(rule)
|
||||
|
||||
return rules
|
||||
|
||||
def find_rule(self, domain):
|
||||
"""
|
||||
查找指定域名的规则
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
dict | None: 规则信息
|
||||
"""
|
||||
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
|
||||
if not os.path.exists(config_path):
|
||||
return None
|
||||
return self._parse_config(config_path)
|
||||
|
||||
def _parse_config(self, filepath):
|
||||
"""
|
||||
解析单个 Nginx 配置文件
|
||||
|
||||
Args:
|
||||
filepath: 配置文件路径
|
||||
|
||||
Returns:
|
||||
dict | None: 解析出的规则信息
|
||||
"""
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
rule = {
|
||||
'config_file': filepath
|
||||
}
|
||||
|
||||
# 提取域名 (server_name)
|
||||
server_name_match = re.search(r'server_name\s+([^;]+);', content)
|
||||
if server_name_match:
|
||||
rule['domain'] = server_name_match.group(1).strip()
|
||||
|
||||
# 提取转发目标 (proxy_pass)
|
||||
proxy_pass_match = re.search(r'proxy_pass\s+([^;]+);', content)
|
||||
if proxy_pass_match:
|
||||
rule['target'] = proxy_pass_match.group(1).strip()
|
||||
|
||||
# 判断协议类型
|
||||
if 'listen 443 ssl' in content:
|
||||
rule['protocol'] = 'https'
|
||||
# 提取 SSL 证书路径
|
||||
cert_match = re.search(r'ssl_certificate\s+([^;]+);', content)
|
||||
if cert_match:
|
||||
rule['ssl_cert'] = cert_match.group(1).strip()
|
||||
key_match = re.search(r'ssl_certificate_key\s+([^;]+);', content)
|
||||
if key_match:
|
||||
rule['ssl_key'] = key_match.group(1).strip()
|
||||
else:
|
||||
rule['protocol'] = 'http'
|
||||
|
||||
return rule
|
||||
except Exception as e:
|
||||
logger.error(f"解析配置文件失败 {filepath}: {e}")
|
||||
return None
|
||||
|
||||
def check_config(self):
|
||||
"""
|
||||
检查 Nginx 配置语法
|
||||
|
||||
Returns:
|
||||
tuple: (是否通过, 错误信息)
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['nginx', '-t'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, 'ok'
|
||||
else:
|
||||
error_msg = result.stderr.strip() or '配置语法错误'
|
||||
return False, error_msg
|
||||
except FileNotFoundError:
|
||||
return False, 'nginx 命令未找到,请确认 Nginx 已安装'
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, 'nginx -t 执行超时'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def reload(self):
|
||||
"""
|
||||
重载 Nginx 配置
|
||||
|
||||
Returns:
|
||||
tuple: (是否成功, 消息)
|
||||
"""
|
||||
# 先检查配置语法
|
||||
valid, msg = self.check_config()
|
||||
if not valid:
|
||||
return False, f'配置语法检查失败: {msg}'
|
||||
|
||||
# 执行 reload
|
||||
try:
|
||||
# 尝试 systemctl reload
|
||||
result = subprocess.run(
|
||||
['systemctl', 'reload', 'nginx'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Nginx 重载成功 (systemctl)")
|
||||
return True, 'Nginx 重载成功'
|
||||
|
||||
# 回退到 nginx -s reload
|
||||
result = subprocess.run(
|
||||
['nginx', '-s', 'reload'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Nginx 重载成功 (nginx -s reload)")
|
||||
return True, 'Nginx 重载成功'
|
||||
|
||||
error_msg = result.stderr.strip() or '未知错误'
|
||||
return False, f'重载失败: {error_msg}'
|
||||
except FileNotFoundError:
|
||||
return False, 'nginx 命令未找到'
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, 'reload 执行超时'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def parse_gzip_config(self, domain):
|
||||
"""
|
||||
从配置文件中解析 gzip 压缩配置
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
|
||||
Returns:
|
||||
dict: gzip 配置信息
|
||||
"""
|
||||
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
|
||||
if not os.path.exists(config_path):
|
||||
return {
|
||||
'enabled': False,
|
||||
'types': [],
|
||||
'comp_level': 6,
|
||||
'min_length': '1000'
|
||||
}
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
result = {
|
||||
'enabled': False,
|
||||
'types': [],
|
||||
'comp_level': 6,
|
||||
'min_length': '1000'
|
||||
}
|
||||
|
||||
# 解析 gzip on/off
|
||||
gzip_match = re.search(r'^\s*gzip\s+(on|off)\s*;', content, re.MULTILINE)
|
||||
if gzip_match:
|
||||
result['enabled'] = gzip_match.group(1) == 'on'
|
||||
|
||||
# 解析 gzip_types
|
||||
types_match = re.search(r'^\s*gzip_types\s+([^;]+);', content, re.MULTILINE)
|
||||
if types_match:
|
||||
types_str = types_match.group(1).strip()
|
||||
result['types'] = [t.strip() for t in types_str.split() if t.strip()]
|
||||
|
||||
# 解析 gzip_comp_level
|
||||
level_match = re.search(r'^\s*gzip_comp_level\s+(\d+)\s*;', content, re.MULTILINE)
|
||||
if level_match:
|
||||
result['comp_level'] = int(level_match.group(1))
|
||||
|
||||
# 解析 gzip_min_length
|
||||
length_match = re.search(r'^\s*gzip_min_length\s+(\S+)\s*;', content, re.MULTILINE)
|
||||
if length_match:
|
||||
result['min_length'] = length_match.group(1).strip()
|
||||
|
||||
return result
|
||||
|
||||
def set_gzip_config(self, domain, gzip_config):
|
||||
"""
|
||||
修改配置文件中的 gzip 压缩配置(在 server 块内添加/更新 gzip 指令)
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
gzip_config: dict with keys:
|
||||
- enabled (bool): 是否开启
|
||||
- types (list[str]): MIME 类型列表
|
||||
- comp_level (int): 压缩级别 1-9
|
||||
- min_length (str): 最小压缩长度
|
||||
|
||||
Returns:
|
||||
tuple: (成功, 消息)
|
||||
"""
|
||||
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
|
||||
if not os.path.exists(config_path):
|
||||
return False, f'域名 {domain} 的配置文件不存在'
|
||||
|
||||
# 读取原内容
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
original_content = f.read()
|
||||
|
||||
content = original_content
|
||||
|
||||
# 1. 删除 server 块内所有已有的 gzip 指令(包括注释行)
|
||||
content = re.sub(r'^\s*#\s*内容压缩[^\n]*\n?', '', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^\s*gzip\s+(on|off)\s*;\s*\n?', '', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^\s*gzip_types\s+[^;]+;\s*\n?', '', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^\s*gzip_comp_level\s+\d+\s*;\s*\n?', '', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^\s*gzip_min_length\s+\S+\s*;\s*\n?', '', content, flags=re.MULTILINE)
|
||||
|
||||
# 2. 构建新的 gzip 指令块
|
||||
gzip_lines = []
|
||||
if gzip_config.get('enabled', False):
|
||||
gzip_lines.append(' # 内容压缩(gzip)')
|
||||
gzip_lines.append(' gzip on;')
|
||||
|
||||
types = gzip_config.get('types', [])
|
||||
if types:
|
||||
types_str = ' '.join(types)
|
||||
gzip_lines.append(f' gzip_types {types_str};')
|
||||
|
||||
comp_level = gzip_config.get('comp_level', 6)
|
||||
gzip_lines.append(f' gzip_comp_level {comp_level};')
|
||||
|
||||
min_length = gzip_config.get('min_length', '1000')
|
||||
gzip_lines.append(f' gzip_min_length {min_length};')
|
||||
else:
|
||||
gzip_lines.append(' # 内容压缩(gzip)')
|
||||
gzip_lines.append(' gzip off;')
|
||||
|
||||
gzip_block = '\n'.join(gzip_lines) + '\n'
|
||||
|
||||
# 3. 在 server_name 行之后插入 gzip 指令块
|
||||
# 匹配 server_name xxx; 并在其后插入
|
||||
def insert_gzip(match):
|
||||
return match.group(0) + '\n' + gzip_block
|
||||
|
||||
content = re.sub(
|
||||
r'^\s*server_name\s+[^;]+;\s*$',
|
||||
insert_gzip,
|
||||
content,
|
||||
flags=re.MULTILINE
|
||||
)
|
||||
|
||||
# 4. 写入文件
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info(f"已更新 {domain} 的 gzip 压缩配置: enabled={gzip_config.get('enabled')}")
|
||||
|
||||
# 5. 语法检查
|
||||
valid, msg = self.check_config()
|
||||
if not valid:
|
||||
# 回滚
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
f.write(original_content)
|
||||
logger.error(f"gzip 配置语法错误,已回滚: {msg}")
|
||||
return False, f'配置语法检查失败,已自动回滚: {msg}'
|
||||
|
||||
# 6. 重载 Nginx
|
||||
reload_success, reload_msg = self.reload()
|
||||
if not reload_success:
|
||||
logger.warning(f"Nginx 重载失败: {reload_msg}")
|
||||
return False, f'gzip 配置已写入但重载失败: {reload_msg}'
|
||||
|
||||
return True, 'gzip 压缩配置已保存并生效'
|
||||
|
||||
def get_domain_logs(self, domain, lines=200):
|
||||
"""
|
||||
获取指定域名的 Nginx 访问日志和错误日志
|
||||
|
||||
Args:
|
||||
domain: 域名
|
||||
lines: 返回最近多少行日志(默认200)
|
||||
|
||||
Returns:
|
||||
dict: access_log 和 error_log 内容
|
||||
"""
|
||||
result = {
|
||||
'domain': domain,
|
||||
'access_log': '',
|
||||
'error_log': '',
|
||||
'access_log_path': '/var/log/nginx/access.log',
|
||||
'error_log_path': '/var/log/nginx/error.log'
|
||||
}
|
||||
|
||||
# 访问日志 — 过滤包含域名的行
|
||||
access_log_path = '/var/log/nginx/access.log'
|
||||
if os.path.exists(access_log_path):
|
||||
try:
|
||||
# 使用 tail + grep 获取最近包含域名的日志行
|
||||
import subprocess as _sp
|
||||
proc = _sp.run(
|
||||
['tail', '-n', str(lines * 5), access_log_path],
|
||||
stdout=_sp.PIPE, stderr=_sp.PIPE,
|
||||
universal_newlines=True, timeout=10
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
all_lines = proc.stdout.splitlines()
|
||||
matched = [l for l in all_lines if domain in l]
|
||||
result['access_log'] = '\n'.join(matched[-lines:])
|
||||
except Exception as e:
|
||||
logger.warning(f"读取访问日志失败: {e}")
|
||||
result['access_log'] = f'[读取失败] {e}'
|
||||
|
||||
# 错误日志 — 过滤包含域名的行
|
||||
error_log_path = '/var/log/nginx/error.log'
|
||||
if os.path.exists(error_log_path):
|
||||
try:
|
||||
import subprocess as _sp
|
||||
proc = _sp.run(
|
||||
['tail', '-n', str(lines * 5), error_log_path],
|
||||
stdout=_sp.PIPE, stderr=_sp.PIPE,
|
||||
universal_newlines=True, timeout=10
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
all_lines = proc.stdout.splitlines()
|
||||
matched = [l for l in all_lines if domain in l]
|
||||
result['error_log'] = '\n'.join(matched[-lines:])
|
||||
except Exception as e:
|
||||
logger.warning(f"读取错误日志失败: {e}")
|
||||
result['error_log'] = f'[读取失败] {e}'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _escape_nginx(content):
|
||||
"""转义 Nginx 配置中的特殊字符(模板用)"""
|
||||
return content
|
||||
532
nginx-python-api/services/ssl_service.py
Normal file
532
nginx-python-api/services/ssl_service.py
Normal file
@@ -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
|
||||
106
scripts/clean_mokee.py
Normal file
106
scripts/clean_mokee.py
Normal file
@@ -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()
|
||||
23
scripts/enable_cors.sh
Normal file
23
scripts/enable_cors.sh
Normal file
@@ -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 "=== 完成 ==="
|
||||
36
scripts/enable_large_upload.sh
Normal file
36
scripts/enable_large_upload.sh
Normal file
@@ -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 ==="
|
||||
32
scripts/fix_nginx_conf.sh
Normal file
32
scripts/fix_nginx_conf.sh
Normal file
@@ -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
|
||||
78
scripts/run_audit_migration.py
Normal file
78
scripts/run_audit_migration.py
Normal file
@@ -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数据库迁移完成!')
|
||||
32
scripts/set_timeout_12h.sh
Normal file
32
scripts/set_timeout_12h.sh
Normal file
@@ -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 ==="
|
||||
151
sql/audit_migration.sql
Normal file
151
sql/audit_migration.sql
Normal file
@@ -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;
|
||||
37
sql/cert_migration.sql
Normal file
37
sql/cert_migration.sql
Normal file
@@ -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');
|
||||
70
sql/gateway_init.sql
Normal file
70
sql/gateway_init.sql
Normal file
@@ -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
|
||||
);
|
||||
78
sql/gzip_migration.sql
Normal file
78
sql/gzip_migration.sql
Normal file
@@ -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%';
|
||||
48
sql/init.sql
Normal file
48
sql/init.sql
Normal file
@@ -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证书管理表';
|
||||
35
test_email.py
Normal file
35
test_email.py
Normal file
@@ -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": "<p>测试邮件</p>",
|
||||
"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()}")
|
||||
Reference in New Issue
Block a user