初始化1

This commit is contained in:
zg
2026-07-16 13:03:11 +08:00
parent 0bdfcbc1c8
commit e6b0f287cc
100 changed files with 12184 additions and 0 deletions

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ test/
hs_err_pid* hs_err_pid*
replay_pid* replay_pid*
.idea

519
DEPLOYMENT.md Normal file
View 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
View 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
View 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
View 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
View 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

View 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
View File

@@ -0,0 +1,2 @@
flask==3.1.0
gunicorn==23.0.0

View 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 cloneget.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 APIacme.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.6Flask 最高只支持到 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-Plus2026-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
View 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文件部署到138systemd开机自启Java配置改1行指新地址编译部署到Docker全链路验证通过。数据库10.1.1.122→10.20.1.122未完成(网络不通已回滚)。"
}

11
memory/MEMORY.md Normal file
View File

@@ -0,0 +1,11 @@
- [会话 2026-06-21 #2](session-20260621-2.md) — DNS解析API从10.1.1.160迁移至101.132.183.138:32210Java后端改指新地址
- [会话 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) — 编辑弹窗新增"内容压缩"Tabgzip配置+ "日志"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 加载加速

View 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 加载速度是否改善

View 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.4GitHub 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

View 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**: nginxServersystemName: 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 服务稳定
- [ ] 用户自行部署 JavaCertificateService 改动)
- [ ] 用户自行构建部署前端MainLayout + 3页面 + 邮箱默认值)

View 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 composableMainLayout 复用 + 侧滑关闭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/htmlJS/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处确保未来新增规则也生效

View 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 CSSJS 改在 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

View 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 连接 SSHpymysql 连接 MySQL
## 待处理
- [ ] 用户自行部署 Java 后端和 Vue 前端
- [ ] 验证前端 Tab 布局弹窗完整功能

View 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` — 内容压缩Tabel-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` — 日志Tabel-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 APIgzip读写均正常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链路

View 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 链路

View 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/updatedByupdateFill 同时填充 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/nginxserverQA和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的记录定期归档

View 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.160Python 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.9710.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解析管理阿里云APInginx将卸载 |
| 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 卸载(用户决定时机)
- [ ] 宝塔面板上创建更多反代项目后验证列表自动刷新

View 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
- **端口**: 32210HTTP
- **结果**: 服务正常运行健康检查通过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.170docker 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.170Docker 主机ping 不通3306 端口不可达
- **处理**: 已回滚,仍使用 10.1.1.122
- **待办**: 网络/防火墙打通后再次修改并部署
### 涉及文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `dns-api/app.py` | **新建** | 精简版 Flask DNS API4个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 服务

View 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"]

View 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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
);
}
}

View File

@@ -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 "系统";
}
}

View File

@@ -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);
}
}

View File

@@ -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"); }
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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, "日志查看请前往宝塔面板"));
}
}

View File

@@ -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()));
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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状态请前往宝塔面板查看");
}
}

View File

@@ -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());
}
}
// ═══════════════════════════════════════════════════════════
// 内容压缩gzipAPI
// ═══════════════════════════════════════════════════════════
/**
* 获取指定域名的 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;
}
}
// ═══════════════════════════════════════════════════════════
// 证书管理 APILet'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());
}
}
}

View File

@@ -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

View 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

View 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

View 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转发管理平台

View 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
View 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?

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View 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

View 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).

View 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>

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View 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

View 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>

View 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)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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 }
}

View 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>

View 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 polyfillWidget 内部引用了 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()

View 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

View 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

View 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>

View 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>

View 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>

View 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
View 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()
# 启动时初始化内部 CAgunicorn 模式下也必须执行)
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)

View 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 "========================================="

View 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

View File

@@ -0,0 +1,2 @@
flask==3.1.0
gunicorn==23.0.0

View File

View 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()

View 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

View File

@@ -0,0 +1,567 @@
"""
Nginx 配置管理服务
负责读写 Nginx 配置文件、重载 Nginx
全局优化(在 /etc/nginx/nginx.conf http 块中):
- gzip + gzip_types14种MIME— 压缩传输
- SSL session cache (10m) + timeout (60m) — 减少重复TLS握手
- ssl_session_tickets off + ssl_buffer_size 4k
每个域名配置的优化:
- listen 443 ssl http2 — HTTP/2 多路复用
- proxy_buffering off — 边收边发,不阻塞
- 静态资源 30天强缓存public, immutable
"""
import os
import re
import subprocess
import logging
logger = logging.getLogger(__name__)
# Nginx HTTP 配置模板
HTTP_TEMPLATE = """# Nginx转发规则 - {domain}
# 协议: HTTP
# 自动生成,请勿手动修改
server {{
listen 80;
server_name {domain};
client_max_body_size 2048m;
# 静态资源缓存JS/CSS 带 hash30天
location ~* \\.(js|css|svg|woff2|png|jpg|ico)$ {{
expires 30d;
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
add_header Cache-Control \"public, immutable\";
proxy_pass {target};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection \"\";
proxy_set_header Accept-Encoding \"gzip\";
proxy_connect_timeout 43200s;
proxy_send_timeout 43200s;
proxy_read_timeout 43200s;
proxy_buffering off;
}}
location / {{
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
proxy_pass {target};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection \"\";
proxy_set_header Accept-Encoding \"gzip\";
proxy_connect_timeout 43200s;
proxy_send_timeout 43200s;
proxy_read_timeout 43200s;
proxy_buffering off;
}}
}}
"""
# Nginx HTTPS 配置模板
HTTPS_TEMPLATE = """# Nginx转发规则 - {domain}
# 协议: HTTPS
# 自动生成,请勿手动修改
# HTTP → HTTPS 重定向
server {{
listen 80;
server_name {domain};
return 301 https://$host$request_uri;
}}
# HTTPS 服务
server {{
listen 443 ssl http2;
server_name {domain};
# CORS 跨域响应头server 级别,确保 OPTIONS 204 也带 CORS 头)
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
# CORS 预检请求快速返回(避免穿透到后端)
if ($request_method = OPTIONS) {{ return 204; }}
ssl_certificate {ssl_cert};
ssl_certificate_key {ssl_key};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 静态资源缓存JS/CSS 带 hash30天
location ~* \\.(js|css|svg|woff2|png|jpg|ico)$ {{
expires 30d;
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
add_header Cache-Control \"public, immutable\";
proxy_pass {target};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection \"\";
proxy_set_header Accept-Encoding \"gzip\";
proxy_connect_timeout 43200s;
proxy_send_timeout 43200s;
proxy_read_timeout 43200s;
proxy_buffering off;
}}
location / {{
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;
add_header Access-Control-Allow-Headers \"Content-Type, Authorization\" always;
proxy_pass {target};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection \"\";
proxy_set_header Accept-Encoding \"gzip\";
proxy_connect_timeout 43200s;
proxy_send_timeout 43200s;
proxy_read_timeout 43200s;
proxy_buffering off;
}}
}}
"""
class NginxService:
"""Nginx 配置管理服务"""
def __init__(self, conf_dir='/etc/nginx/conf.d', ssl_dir='/etc/nginx/ssl'):
self.conf_dir = conf_dir
self.ssl_dir = ssl_dir
# 确保目录存在
os.makedirs(self.conf_dir, exist_ok=True)
def create_config(self, domain, protocol, target, ssl_cert=None, ssl_key=None):
"""
创建域名转发配置文件
Args:
domain: 域名
protocol: 协议 (http/https)
target: 转发目标地址
ssl_cert: SSL证书路径仅在HTTPS时需要
ssl_key: SSL私钥路径仅在HTTPS时需要
Returns:
config_file: 生成的配置文件路径
"""
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
if protocol == 'https':
content = HTTPS_TEMPLATE.format(
domain=domain,
target=target,
ssl_cert=ssl_cert or f'{self.ssl_dir}/{domain}.pem',
ssl_key=ssl_key or f'{self.ssl_dir}/{domain}.key'
)
else:
content = HTTP_TEMPLATE.format(
domain=domain,
target=target
)
with open(config_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"已创建配置文件: {config_path}")
return config_path
def delete_config(self, domain):
"""
删除域名配置文件
Args:
domain: 域名
Returns:
bool: 是否成功删除
"""
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
if os.path.exists(config_path):
os.remove(config_path)
logger.info(f"已删除配置文件: {config_path}")
return True
logger.warning(f"配置文件不存在: {config_path}")
return False
def list_rules(self):
"""
解析所有 Nginx 配置文件,提取转发规则列表
Returns:
list[dict]: 规则列表
"""
rules = []
if not os.path.exists(self.conf_dir):
return rules
for filename in sorted(os.listdir(self.conf_dir)):
if not filename.endswith('.conf'):
continue
filepath = os.path.join(self.conf_dir, filename)
rule = self._parse_config(filepath)
if rule:
rules.append(rule)
return rules
def find_rule(self, domain):
"""
查找指定域名的规则
Args:
domain: 域名
Returns:
dict | None: 规则信息
"""
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
if not os.path.exists(config_path):
return None
return self._parse_config(config_path)
def _parse_config(self, filepath):
"""
解析单个 Nginx 配置文件
Args:
filepath: 配置文件路径
Returns:
dict | None: 解析出的规则信息
"""
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
rule = {
'config_file': filepath
}
# 提取域名 (server_name)
server_name_match = re.search(r'server_name\s+([^;]+);', content)
if server_name_match:
rule['domain'] = server_name_match.group(1).strip()
# 提取转发目标 (proxy_pass)
proxy_pass_match = re.search(r'proxy_pass\s+([^;]+);', content)
if proxy_pass_match:
rule['target'] = proxy_pass_match.group(1).strip()
# 判断协议类型
if 'listen 443 ssl' in content:
rule['protocol'] = 'https'
# 提取 SSL 证书路径
cert_match = re.search(r'ssl_certificate\s+([^;]+);', content)
if cert_match:
rule['ssl_cert'] = cert_match.group(1).strip()
key_match = re.search(r'ssl_certificate_key\s+([^;]+);', content)
if key_match:
rule['ssl_key'] = key_match.group(1).strip()
else:
rule['protocol'] = 'http'
return rule
except Exception as e:
logger.error(f"解析配置文件失败 {filepath}: {e}")
return None
def check_config(self):
"""
检查 Nginx 配置语法
Returns:
tuple: (是否通过, 错误信息)
"""
try:
result = subprocess.run(
['nginx', '-t'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True,
timeout=10
)
if result.returncode == 0:
return True, 'ok'
else:
error_msg = result.stderr.strip() or '配置语法错误'
return False, error_msg
except FileNotFoundError:
return False, 'nginx 命令未找到,请确认 Nginx 已安装'
except subprocess.TimeoutExpired:
return False, 'nginx -t 执行超时'
except Exception as e:
return False, str(e)
def reload(self):
"""
重载 Nginx 配置
Returns:
tuple: (是否成功, 消息)
"""
# 先检查配置语法
valid, msg = self.check_config()
if not valid:
return False, f'配置语法检查失败: {msg}'
# 执行 reload
try:
# 尝试 systemctl reload
result = subprocess.run(
['systemctl', 'reload', 'nginx'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True,
timeout=30
)
if result.returncode == 0:
logger.info("Nginx 重载成功 (systemctl)")
return True, 'Nginx 重载成功'
# 回退到 nginx -s reload
result = subprocess.run(
['nginx', '-s', 'reload'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True,
timeout=30
)
if result.returncode == 0:
logger.info("Nginx 重载成功 (nginx -s reload)")
return True, 'Nginx 重载成功'
error_msg = result.stderr.strip() or '未知错误'
return False, f'重载失败: {error_msg}'
except FileNotFoundError:
return False, 'nginx 命令未找到'
except subprocess.TimeoutExpired:
return False, 'reload 执行超时'
except Exception as e:
return False, str(e)
def parse_gzip_config(self, domain):
"""
从配置文件中解析 gzip 压缩配置
Args:
domain: 域名
Returns:
dict: gzip 配置信息
"""
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
if not os.path.exists(config_path):
return {
'enabled': False,
'types': [],
'comp_level': 6,
'min_length': '1000'
}
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
result = {
'enabled': False,
'types': [],
'comp_level': 6,
'min_length': '1000'
}
# 解析 gzip on/off
gzip_match = re.search(r'^\s*gzip\s+(on|off)\s*;', content, re.MULTILINE)
if gzip_match:
result['enabled'] = gzip_match.group(1) == 'on'
# 解析 gzip_types
types_match = re.search(r'^\s*gzip_types\s+([^;]+);', content, re.MULTILINE)
if types_match:
types_str = types_match.group(1).strip()
result['types'] = [t.strip() for t in types_str.split() if t.strip()]
# 解析 gzip_comp_level
level_match = re.search(r'^\s*gzip_comp_level\s+(\d+)\s*;', content, re.MULTILINE)
if level_match:
result['comp_level'] = int(level_match.group(1))
# 解析 gzip_min_length
length_match = re.search(r'^\s*gzip_min_length\s+(\S+)\s*;', content, re.MULTILINE)
if length_match:
result['min_length'] = length_match.group(1).strip()
return result
def set_gzip_config(self, domain, gzip_config):
"""
修改配置文件中的 gzip 压缩配置(在 server 块内添加/更新 gzip 指令)
Args:
domain: 域名
gzip_config: dict with keys:
- enabled (bool): 是否开启
- types (list[str]): MIME 类型列表
- comp_level (int): 压缩级别 1-9
- min_length (str): 最小压缩长度
Returns:
tuple: (成功, 消息)
"""
config_path = os.path.join(self.conf_dir, f'{domain}.conf')
if not os.path.exists(config_path):
return False, f'域名 {domain} 的配置文件不存在'
# 读取原内容
with open(config_path, 'r', encoding='utf-8') as f:
original_content = f.read()
content = original_content
# 1. 删除 server 块内所有已有的 gzip 指令(包括注释行)
content = re.sub(r'^\s*#\s*内容压缩[^\n]*\n?', '', content, flags=re.MULTILINE)
content = re.sub(r'^\s*gzip\s+(on|off)\s*;\s*\n?', '', content, flags=re.MULTILINE)
content = re.sub(r'^\s*gzip_types\s+[^;]+;\s*\n?', '', content, flags=re.MULTILINE)
content = re.sub(r'^\s*gzip_comp_level\s+\d+\s*;\s*\n?', '', content, flags=re.MULTILINE)
content = re.sub(r'^\s*gzip_min_length\s+\S+\s*;\s*\n?', '', content, flags=re.MULTILINE)
# 2. 构建新的 gzip 指令块
gzip_lines = []
if gzip_config.get('enabled', False):
gzip_lines.append(' # 内容压缩gzip')
gzip_lines.append(' gzip on;')
types = gzip_config.get('types', [])
if types:
types_str = ' '.join(types)
gzip_lines.append(f' gzip_types {types_str};')
comp_level = gzip_config.get('comp_level', 6)
gzip_lines.append(f' gzip_comp_level {comp_level};')
min_length = gzip_config.get('min_length', '1000')
gzip_lines.append(f' gzip_min_length {min_length};')
else:
gzip_lines.append(' # 内容压缩gzip')
gzip_lines.append(' gzip off;')
gzip_block = '\n'.join(gzip_lines) + '\n'
# 3. 在 server_name 行之后插入 gzip 指令块
# 匹配 server_name xxx; 并在其后插入
def insert_gzip(match):
return match.group(0) + '\n' + gzip_block
content = re.sub(
r'^\s*server_name\s+[^;]+;\s*$',
insert_gzip,
content,
flags=re.MULTILINE
)
# 4. 写入文件
with open(config_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"已更新 {domain} 的 gzip 压缩配置: enabled={gzip_config.get('enabled')}")
# 5. 语法检查
valid, msg = self.check_config()
if not valid:
# 回滚
with open(config_path, 'w', encoding='utf-8') as f:
f.write(original_content)
logger.error(f"gzip 配置语法错误,已回滚: {msg}")
return False, f'配置语法检查失败,已自动回滚: {msg}'
# 6. 重载 Nginx
reload_success, reload_msg = self.reload()
if not reload_success:
logger.warning(f"Nginx 重载失败: {reload_msg}")
return False, f'gzip 配置已写入但重载失败: {reload_msg}'
return True, 'gzip 压缩配置已保存并生效'
def get_domain_logs(self, domain, lines=200):
"""
获取指定域名的 Nginx 访问日志和错误日志
Args:
domain: 域名
lines: 返回最近多少行日志默认200
Returns:
dict: access_log 和 error_log 内容
"""
result = {
'domain': domain,
'access_log': '',
'error_log': '',
'access_log_path': '/var/log/nginx/access.log',
'error_log_path': '/var/log/nginx/error.log'
}
# 访问日志 — 过滤包含域名的行
access_log_path = '/var/log/nginx/access.log'
if os.path.exists(access_log_path):
try:
# 使用 tail + grep 获取最近包含域名的日志行
import subprocess as _sp
proc = _sp.run(
['tail', '-n', str(lines * 5), access_log_path],
stdout=_sp.PIPE, stderr=_sp.PIPE,
universal_newlines=True, timeout=10
)
if proc.returncode == 0:
all_lines = proc.stdout.splitlines()
matched = [l for l in all_lines if domain in l]
result['access_log'] = '\n'.join(matched[-lines:])
except Exception as e:
logger.warning(f"读取访问日志失败: {e}")
result['access_log'] = f'[读取失败] {e}'
# 错误日志 — 过滤包含域名的行
error_log_path = '/var/log/nginx/error.log'
if os.path.exists(error_log_path):
try:
import subprocess as _sp
proc = _sp.run(
['tail', '-n', str(lines * 5), error_log_path],
stdout=_sp.PIPE, stderr=_sp.PIPE,
universal_newlines=True, timeout=10
)
if proc.returncode == 0:
all_lines = proc.stdout.splitlines()
matched = [l for l in all_lines if domain in l]
result['error_log'] = '\n'.join(matched[-lines:])
except Exception as e:
logger.warning(f"读取错误日志失败: {e}")
result['error_log'] = f'[读取失败] {e}'
return result
def _escape_nginx(content):
"""转义 Nginx 配置中的特殊字符(模板用)"""
return content

View 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
View 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
View 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 "=== 完成 ==="

View 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
View 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

View 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数据库迁移完成!')

View 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
View 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
View 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
View 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
View 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
View 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
View 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()}")