Initial commit: OpenVPN Manager v1.0
OpenVPN Web management console with multi-instance support, client cert issuance, traffic/connection auditing, certificate expiry reminders, auto backup/restore. Stack: - Backend: Go 1.21+ (Gin + JWT) - Frontend: Vue 3 + Element Plus + ECharts + Vite - Storage: JSON file (db.json) + filesystem (pki/, instances/, clients/, backups/) Features: - Multi-instance OpenVPN management (independent port/proto/subnet/PKI) - One-click client certificate issuance with .ovpn (embedded certs) - Certificate expiry reminders (30-day threshold) - Connection log parsing (status-version 3) - Auto backup/restore (tar.gz) - Audit log for all write operations - JWT auth (12h TTL) - One-line install.sh for Ubuntu/Debian/RHEL/Fedora
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
data/
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 OpenVPN Manager Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
# OpenVPN Manager
|
||||||
|
|
||||||
|
> 一个开箱即用的 OpenVPN Web 管理控制台 —— 多实例托管、客户端证书一键签发、流量审计、证书到期提醒、自动备份与恢复。
|
||||||
|
> 基于 **Go (Gin) + Vue 3 + Element Plus + ECharts**,单二进制部署。
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [架构概览](#架构概览)
|
||||||
|
- [一、服务端部署](#一服务端部署)
|
||||||
|
- [系统要求](#系统要求)
|
||||||
|
- [一键安装](#一键安装)
|
||||||
|
- [安装参数](#安装参数)
|
||||||
|
- [手动安装](#手动安装)
|
||||||
|
- [目录结构](#目录结构)
|
||||||
|
- [systemd 管理](#systemd-管理)
|
||||||
|
- [升级](#升级)
|
||||||
|
- [卸载](#卸载)
|
||||||
|
- [二、首次配置](#二首次配置)
|
||||||
|
- [登录 Web 控制台](#登录-web-控制台)
|
||||||
|
- [修改默认密码](#修改默认密码)
|
||||||
|
- [创建第一个实例](#创建第一个实例)
|
||||||
|
- [创建客户端用户并下载配置](#创建客户端用户并下载配置)
|
||||||
|
- [防火墙与公网暴露](#防火墙与公网暴露)
|
||||||
|
- [三、客户端使用](#三客户端使用)
|
||||||
|
- [配置文件说明](#配置文件说明)
|
||||||
|
- [Windows](#windows)
|
||||||
|
- [macOS](#macos)
|
||||||
|
- [Linux](#linux)
|
||||||
|
- [Android](#android)
|
||||||
|
- [iOS](#ios)
|
||||||
|
- [验证连接](#验证连接)
|
||||||
|
- [四、API 参考](#四api-参考)
|
||||||
|
- [五、常见问题](#五常见问题)
|
||||||
|
- [六、开发](#六开发)
|
||||||
|
- [许可证](#许可证)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 多实例管理 | 同一台机器上跑多个 OpenVPN 实例,每个独立端口/协议/子网/PKI |
|
||||||
|
| 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 |
|
||||||
|
| 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP |
|
||||||
|
| 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 |
|
||||||
|
| 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 |
|
||||||
|
| 证书到期提醒 | 仪表盘统计 30 天内到期的证书,单独证书管理页查看完整清单 |
|
||||||
|
| 自动备份 | 一键打包 PKI + 实例配置 + 客户端配置为 tar.gz |
|
||||||
|
| 一键恢复 | 上传/选择已有备份,直接覆盖还原 |
|
||||||
|
| 操作审计 | 所有写操作(创建/修改/删除/吊销/备份)持久化,记录操作者/IP/结果 |
|
||||||
|
| JWT 鉴权 | 登录后 12 小时有效 token,无状态可水平扩展 |
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ 浏览器 (Vue 3 SPA) │
|
||||||
|
│ Element Plus + ECharts │
|
||||||
|
└──────────────┬───────────┘
|
||||||
|
│ HTTPS/HTTP (8089)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐ ┌────────────────────────┐
|
||||||
|
│ Go HTTP 服务 (Gin) │ ◄──┐ │ systemd: │
|
||||||
|
│ - JWT 鉴权 │ │ │ openvpn-manager.service│
|
||||||
|
│ - REST API │ │ └────────────────────────┘
|
||||||
|
│ - OpenVPN 进程管理 │ │
|
||||||
|
│ - OpenSSL 证书签发/吊销 │ │ ┌────────────────────────┐
|
||||||
|
└──────────────┬───────────┘ │ │ /usr/sbin/openvpn │
|
||||||
|
│ ├───►│ --config <conf> │
|
||||||
|
│ │ │ --cd <dir> │
|
||||||
|
▼ │ │ → status.log │
|
||||||
|
┌──────────────────────────┐ │ └────────────────────────┘
|
||||||
|
│ <DataDir> │ │
|
||||||
|
│ pki/ │ │
|
||||||
|
│ instances/<name>/... │ │
|
||||||
|
│ clients/<inst>/<user>/ │ │
|
||||||
|
│ backups/ │ │
|
||||||
|
│ db.json │ │
|
||||||
|
└──────────────────────────┘ │
|
||||||
|
│
|
||||||
|
┌────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
(所有写操作) → audit_logs[]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 一、服务端部署
|
||||||
|
|
||||||
|
## 系统要求
|
||||||
|
|
||||||
|
- **操作系统**:Ubuntu 20.04+ / Debian 11+ / CentOS Stream 9+ / Rocky 9+
|
||||||
|
- **CPU**:1 核即可
|
||||||
|
- **内存**:最低 512 MB
|
||||||
|
- **磁盘**:1 GB(证书 + 用户越多占用越大)
|
||||||
|
- **网络**:开放 `8089` 端口(管理界面) + 每个 OpenVPN 实例一个 UDP/TCP 端口(默认 1194)
|
||||||
|
- **权限**:必须以 `root` 启动(OpenVPN 需要 `TUN/TAP` 设备权限)
|
||||||
|
- **依赖工具**(脚本自动安装):
|
||||||
|
- `openvpn` ≥ 2.5
|
||||||
|
- `openssl` ≥ 1.1
|
||||||
|
- `nodejs` ≥ 18
|
||||||
|
- `npm`
|
||||||
|
- `golang` ≥ 1.21
|
||||||
|
|
||||||
|
## 一键安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.cnbugs.com:10022/AI-Agent/openvpn-manager.git
|
||||||
|
cd openvpn-manager
|
||||||
|
chmod +x scripts/install.sh
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会做这些事:
|
||||||
|
1. 识别发行版,自动选 apt/dnf/yum
|
||||||
|
2. 安装系统依赖
|
||||||
|
3. `npm install` + `npm run build` 构建前端
|
||||||
|
4. `go build` 编译后端二进制
|
||||||
|
5. 拷贝到 `/opt/openvpn-manager`
|
||||||
|
6. 注册 systemd 单元并启动
|
||||||
|
7. 健康检查 `/api/health`
|
||||||
|
8. 打印访问地址和默认账号
|
||||||
|
|
||||||
|
成功后会看到:
|
||||||
|
|
||||||
|
```
|
||||||
|
================================================================
|
||||||
|
OpenVPN Manager 安装完成
|
||||||
|
================================================================
|
||||||
|
访问地址 : http://<服务器IP>:8089
|
||||||
|
用户名 : admin
|
||||||
|
密码 : admin123
|
||||||
|
安装目录 : /opt/openvpn-manager
|
||||||
|
数据目录 : /opt/openvpn-manager/data
|
||||||
|
配置单元 : /etc/systemd/system/openvpn-manager.service
|
||||||
|
================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/install.sh \
|
||||||
|
--port 9090 \
|
||||||
|
--user admin \
|
||||||
|
--pass 'StrongPass!2026' \
|
||||||
|
--dir /opt/openvpn-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
| ---- | ------ | ---- |
|
||||||
|
| `--port` | `8089` | Web 管理端口 |
|
||||||
|
| `--user` | `admin` | 初始管理员用户名 |
|
||||||
|
| `--pass` | `admin123` | 初始管理员密码,**生产环境必须改** |
|
||||||
|
| `--dir` | `/opt/openvpn-manager` | 安装目录 |
|
||||||
|
| `-u, --uninstall` | - | 卸载(停服务、删除安装目录、清理 systemd) |
|
||||||
|
|
||||||
|
> JWT 签名密钥会在安装时自动生成 64 位随机十六进制串写入 `/etc/systemd/system/openvpn-manager.service` 的 `OVPNMGR_JWT_SECRET` 环境变量,**不要复制生产环境的这个文件**。
|
||||||
|
|
||||||
|
## 手动安装
|
||||||
|
|
||||||
|
适合不想用 systemd 的场景(如 Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 装依赖
|
||||||
|
apt-get install -y openvpn openssl nodejs npm golang-go git # Debian/Ubuntu
|
||||||
|
# 或
|
||||||
|
dnf install -y openvpn openssl nodejs npm golang git # RHEL/Fedora
|
||||||
|
|
||||||
|
# 2. 编译
|
||||||
|
git clone <this-repo> && cd openvpn-manager
|
||||||
|
cd frontend && npm install --include=dev && npm run build && cd ..
|
||||||
|
cd backend && go build -o ../bin/openvpn-manager ./cmd/server && cd ..
|
||||||
|
|
||||||
|
# 3. 运行
|
||||||
|
mkdir -p data
|
||||||
|
OVPNMGR_PORT=8089 \
|
||||||
|
OVPNMGR_ADMIN_USER=admin \
|
||||||
|
OVPNMGR_ADMIN_PASS='your-password' \
|
||||||
|
OVPNMGR_JWT_SECRET=$(openssl rand -hex 32) \
|
||||||
|
./bin/openvpn-manager --data ./data --dist ./dist
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/openvpn-manager/
|
||||||
|
├── bin/openvpn-manager # Go 编译后的二进制 (~21MB)
|
||||||
|
├── dist/ # Vue 构建产物 (index.html + assets/)
|
||||||
|
├── data/ # 数据目录,定期备份
|
||||||
|
│ ├── db.json # 元数据(JSON):实例/用户/审计
|
||||||
|
│ ├── pki/
|
||||||
|
│ │ ├── ca.crt # CA 证书(全局共享)
|
||||||
|
│ │ ├── ca.key # CA 私钥(权限 0600)
|
||||||
|
│ │ ├── dh.pem # DH 参数
|
||||||
|
│ │ └── ta.key # TLS-Auth 共享密钥
|
||||||
|
│ ├── instances/
|
||||||
|
│ │ └── <instance-name>/
|
||||||
|
│ │ ├── server.conf # OpenVPN 服务端配置
|
||||||
|
│ │ ├── status.log # OpenVPN 状态文件(10s 周期)
|
||||||
|
│ │ ├── ipp.txt # IP 池持久化
|
||||||
|
│ │ ├── ccd/ # 客户端配置目录(固定 IP)
|
||||||
|
│ │ ├── logs/openvpn.log
|
||||||
|
│ │ └── pki/
|
||||||
|
│ │ ├── issued/<cn>.crt
|
||||||
|
│ │ └── private/<cn>.key
|
||||||
|
│ ├── clients/
|
||||||
|
│ │ └── <instance>/<user>.ovpn
|
||||||
|
│ └── backups/ # 自动备份目录
|
||||||
|
└── .env # 环境变量(权限 0600)
|
||||||
|
```
|
||||||
|
|
||||||
|
## systemd 管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status openvpn-manager # 状态
|
||||||
|
systemctl restart openvpn-manager # 重启
|
||||||
|
systemctl stop openvpn-manager # 停止
|
||||||
|
journalctl -u openvpn-manager -f # 跟踪日志(ctrl+c 退出)
|
||||||
|
journalctl -u openvpn-manager -n 200 # 最近 200 行
|
||||||
|
```
|
||||||
|
|
||||||
|
修改配置后需要:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl edit openvpn-manager # 改环境变量(创建 override.conf)
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart openvpn-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
或者直接编辑主单元:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl edit --full openvpn-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
## 升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/openvpn-manager
|
||||||
|
git pull
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` 会:
|
||||||
|
- 重新编译并覆盖二进制
|
||||||
|
- 保留 `data/`、`backups/`、`dist/`(原压缩产物)
|
||||||
|
- 重启服务
|
||||||
|
|
||||||
|
**重要**:升级前请先在 Web 界面"备份与恢复"页面手动做一次备份,以防万一。
|
||||||
|
|
||||||
|
## 卸载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/install.sh -u
|
||||||
|
```
|
||||||
|
|
||||||
|
此命令会:停服务、禁用自启、删除 `/etc/systemd/system/openvpn-manager.service`、删除 `/opt/openvpn-manager`。
|
||||||
|
|
||||||
|
**源码目录不会被删除**,如需彻底清理请手动 `rm -rf`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 二、首次配置
|
||||||
|
|
||||||
|
## 登录 Web 控制台
|
||||||
|
|
||||||
|
浏览器访问 `http://<服务器IP>:8089/`。
|
||||||
|
|
||||||
|
默认账号:
|
||||||
|
- 用户名:`admin`
|
||||||
|
- 密码:`admin123`
|
||||||
|
|
||||||
|
## 修改默认密码
|
||||||
|
|
||||||
|
⚠️ **生产环境第一步**。
|
||||||
|
|
||||||
|
当前版本通过 systemd 环境变量修改密码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl edit --full openvpn-manager
|
||||||
|
# 找到 OVPNMGR_ADMIN_PASS 一行,改成你的强密码
|
||||||
|
# 也建议修改 OVPNMGR_JWT_SECRET 为随机串
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart openvpn-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
或重新跑 `install.sh --pass 新密码`。
|
||||||
|
|
||||||
|
## 创建第一个实例
|
||||||
|
|
||||||
|
Web → "实例管理" → "新建实例":
|
||||||
|
|
||||||
|
| 字段 | 推荐值 | 说明 |
|
||||||
|
| ---- | ------ | ---- |
|
||||||
|
| 名称 | `prod` | 字母数字下划线,作为目录名,不可重复 |
|
||||||
|
| 端口 | `1194` | OpenVPN 监听端口,不能与已有服务冲突 |
|
||||||
|
| 协议 | `udp` | `udp` 性能好,`tcp` 穿透性强 |
|
||||||
|
| 设备 | `tun` | `tun` 路由模式(常用),`tap` 桥接模式 |
|
||||||
|
| 子网 | `10.8.0.0/24` | 给客户端分配的 VPN 内网网段 |
|
||||||
|
| 加密 | `AES-256-GCM` | `AES-128-GCM` 更快,`CHACHA20-POLY1305` 适合 ARM |
|
||||||
|
| 摘要 | `SHA256` | |
|
||||||
|
| 推送 DNS | `dhcp-option DNS 1.1.1.1`<br>`dhcp-option DNS 8.8.8.8` | 一行一条 |
|
||||||
|
| 推送路由 | `192.168.1.0 255.255.255.0` | 让客户端能访问内网,每行一条 CIDR |
|
||||||
|
|
||||||
|
点"保存"会自动:
|
||||||
|
- 创建实例目录
|
||||||
|
- 用全局 CA 签发服务端证书
|
||||||
|
- 生成 `server.conf`
|
||||||
|
|
||||||
|
然后点列表里的"启动"按钮即可。如果失败,看 `journalctl -u openvpn-manager -n 50`。
|
||||||
|
|
||||||
|
## 创建客户端用户并下载配置
|
||||||
|
|
||||||
|
Web → "用户管理":
|
||||||
|
|
||||||
|
1. 顶部下拉框选择实例
|
||||||
|
2. "新建用户":
|
||||||
|
- 用户名(CN):`alice`(字母数字下划线,作为证书 CN)
|
||||||
|
- 备注:`Alice 张三`
|
||||||
|
- 邮箱:`alice@example.com`
|
||||||
|
- 固定 IP:留空为动态;若填 `10.8.0.10`,Alice 每次连上都是这个 VPN IP
|
||||||
|
3. 保存后表格出现 alice,点"下载 .ovpn"
|
||||||
|
|
||||||
|
下载的文件约 4-5 KB,里面已经内嵌了:
|
||||||
|
- CA 证书(`<ca>...</ca>`)
|
||||||
|
- 客户端证书(`<cert>...</cert>`)
|
||||||
|
- 客户端私钥(`<key>...</key>`)
|
||||||
|
- TLS-Auth 密钥(`<tls-auth>...</tls-auth>`,`key-direction 1`)
|
||||||
|
|
||||||
|
直接发给用户即可,无需额外的 `ca.crt` 等文件。
|
||||||
|
|
||||||
|
## 防火墙与公网暴露
|
||||||
|
|
||||||
|
1. Web 管理端口(默认 8089)**不建议直接暴露公网**,建议:
|
||||||
|
- 用防火墙只允许公司/家庭 IP 访问
|
||||||
|
- 或反代 + HTTPS + Basic Auth
|
||||||
|
- 或 SSH 端口转发 `ssh -L 8089:127.0.0.1:8089 user@server`
|
||||||
|
|
||||||
|
2. OpenVPN 实例端口**必须**开放给需要连入的客户端。UDP 优先(性能好):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ufw
|
||||||
|
ufw allow 1194/udp
|
||||||
|
ufw allow 1194/tcp # 如果实例用 TCP
|
||||||
|
|
||||||
|
# firewalld
|
||||||
|
firewall-cmd --permanent --add-port=1194/udp
|
||||||
|
firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 若服务器在 NAT 后(如家用宽带),需要在路由器做端口映射 UDP 1194 → 服务器内网 IP。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 三、客户端使用
|
||||||
|
|
||||||
|
## 配置文件说明
|
||||||
|
|
||||||
|
下载的 `alice.ovpn` 是单一文件,内容大致为:
|
||||||
|
|
||||||
|
```
|
||||||
|
client
|
||||||
|
dev tun
|
||||||
|
proto udp
|
||||||
|
remote vpn.example.com 1194
|
||||||
|
resolv-retry infinite
|
||||||
|
nobind
|
||||||
|
persist-key
|
||||||
|
persist-tun
|
||||||
|
cipher AES-256-GCM
|
||||||
|
auth SHA256
|
||||||
|
remote-cert-tls server
|
||||||
|
verb 3
|
||||||
|
|
||||||
|
<ca>
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
... CA 证书内容 ...
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
</ca>
|
||||||
|
|
||||||
|
<cert>
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
... 客户端证书 ...
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
</cert>
|
||||||
|
|
||||||
|
<key>
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
... 客户端私钥(请勿泄露)...
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
</key>
|
||||||
|
|
||||||
|
<tls-auth>
|
||||||
|
-----BEGIN OpenVPN Static key V1-----
|
||||||
|
... TLS-Auth 共享密钥 ...
|
||||||
|
-----END OpenVPN Static key V1-----
|
||||||
|
</tls-auth>
|
||||||
|
key-direction 1
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `remote` 行是客户端实际连接的服务器地址,**默认是 `vpn.example.com`,需要改成你自己的服务器公网域名/IP**。
|
||||||
|
|
||||||
|
修改方法:
|
||||||
|
- 在 Web 界面"用户管理"页面,顶部"客户端连接的远端域名/IP"输入框填入你的服务器地址(如 `vpn.your-domain.com` 或 `1.2.3.4`),再点"下载 .ovpn"
|
||||||
|
- 或下载后用文本编辑器手动改 `remote` 行
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
**推荐**:OpenVPN 官方 GUI 客户端
|
||||||
|
|
||||||
|
1. 下载:https://openvpn.net/community-downloads/ → 选择 "Windows 64-bit MSI installer"
|
||||||
|
2. 安装(一路下一步,会安装一个虚拟网卡驱动,需要管理员权限)
|
||||||
|
3. 把 `alice.ovpn` 放到 `C:\Users\<你>\OpenVPN\config\`
|
||||||
|
4. 启动 "OpenVPN GUI"(开始菜单里),右下角会出现托盘图标
|
||||||
|
5. 右键托盘图标 → Connect
|
||||||
|
6. 第一次会弹窗请求管理员权限(用于配置路由)
|
||||||
|
7. 连接成功后托盘变绿,会分配一个 `10.8.0.x` 的 VPN IP
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
ipconfig /all
|
||||||
|
# 看到 "10.8.0.x" 的 Tap adapter IPv4 地址即成功
|
||||||
|
|
||||||
|
ping 10.8.0.1
|
||||||
|
# 应该通(10.8.0.1 是 OpenVPN 服务端在子网里的网关)
|
||||||
|
```
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
**选项 1:Tunnelblick**(免费开源,推荐)
|
||||||
|
|
||||||
|
1. 下载:https://tunnelblick.net/
|
||||||
|
2. 安装,会自动安装 `tun` 驱动
|
||||||
|
3. 双击 `alice.ovpn`,Tunnelblick 会问你"是否为所有用户安装",选"仅我"即可
|
||||||
|
4. 菜单栏点 Tunnelblick 图标 → Connect alice
|
||||||
|
5. 状态变绿即成功
|
||||||
|
|
||||||
|
**选项 2:OpenVPN Connect**(官方)
|
||||||
|
|
||||||
|
从 Mac App Store 搜索 "OpenVPN" 安装。
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
### 命令行 (systemd 服务)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt-get install -y openvpn
|
||||||
|
|
||||||
|
# RHEL/Fedora
|
||||||
|
sudo dnf install -y openvpn
|
||||||
|
|
||||||
|
# 连接
|
||||||
|
sudo openvpn --config alice.ovpn --daemon
|
||||||
|
# 或前台运行(能看到日志)
|
||||||
|
sudo openvpn --config alice.ovpn
|
||||||
|
```
|
||||||
|
|
||||||
|
### NetworkManager 图形客户端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y network-manager-openvpn network-manager-openvpn-gnome # Debian/Ubuntu
|
||||||
|
sudo dnf install -y NetworkManager-openvpn NetworkManager-openvpn-gnome # Fedora
|
||||||
|
```
|
||||||
|
|
||||||
|
设置 → 网络 → + VPN → "从文件导入 VPN" → 选 `alice.ovpn` → 保存 → 连接。
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
1. 安装"OpenVPN Connect"(Google Play / F-Droid 都有)
|
||||||
|
2. 把 `alice.ovpn` 通过 USB / 邮件 / 网盘传到手机
|
||||||
|
3. 用文件管理器打开 `.ovpn`,系统会询问"用 OpenVPN 打开"
|
||||||
|
4. 点右上角"连接"
|
||||||
|
5. 首次会提示接受 VPN 配置,点确定
|
||||||
|
6. 通知栏出现钥匙图标即连接成功
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
1. App Store 搜索"OpenVPN"安装
|
||||||
|
2. 用"文件"App 把 `alice.ovpn` 传到手机(隔空投送也行)
|
||||||
|
3. 在"文件"App 里点击 `.ovpn`,选择"用 OpenVPN 打开"
|
||||||
|
4. 点"ADD"导入 → 点右上角开关连接
|
||||||
|
5. 系统会弹窗请求添加 VPN 配置,允许
|
||||||
|
6. 设置 → 通用 → VPN 里可以看到状态
|
||||||
|
|
||||||
|
## 验证连接
|
||||||
|
|
||||||
|
无论哪个平台,连接成功后都可以这样验证:
|
||||||
|
|
||||||
|
1. **VPN IP**:看分配的 IP 是否在配置的子网里(如 `10.8.0.x`)
|
||||||
|
2. **Ping 服务端**:从客户端 `ping 10.8.0.1` 应该通
|
||||||
|
3. **公网出口**:从客户端 `curl ifconfig.me` 应显示服务器公网 IP
|
||||||
|
4. **DNS 解析**:如果推送了 DNS,客户端 `/etc/resolv.conf`(Linux)应看到推送的 DNS
|
||||||
|
|
||||||
|
在 Web 控制台:
|
||||||
|
- "仪表盘"会实时显示当前在线客户端数(10 秒刷新)
|
||||||
|
- "实例管理" → 点实例 → 不会直接显示在线,但可以看 status.log
|
||||||
|
- "连接日志"显示历史连接记录
|
||||||
|
|
||||||
|
### 排障
|
||||||
|
|
||||||
|
| 症状 | 可能原因 |
|
||||||
|
| ---- | -------- |
|
||||||
|
| 连接后立刻断开 | 客户端证书与 CA 不匹配;服务端证书过期 |
|
||||||
|
| 拿到 IP 但 ping 不通服务端 | 防火墙没允许 UDP 1194;服务端没启用 IP 转发 |
|
||||||
|
| 拿到 IP 但访问不了互联网 | 没推送 DNS,或客户端没把 VPN 设为默认网关 |
|
||||||
|
| Android 连不上 | 服务器在 NAT 后,检查运营商是否屏蔽 UDP |
|
||||||
|
| iOS 连不上 | 看 OpenVPN 日志(应用内 OpenVPN → Settings → Log) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 四、API 参考
|
||||||
|
|
||||||
|
所有 `/api` 路径(除 `/login`、`/health`)都需要 `Authorization: Bearer <token>`。
|
||||||
|
|
||||||
|
完整列表见 [`docs/API.md`](docs/API.md) 或启动后查看 `internal/api/router.go`。
|
||||||
|
|
||||||
|
简单列几个常用的:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| ---- | ---- | ---- |
|
||||||
|
| POST | `/api/login` | 登录,返回 `{token, username}` |
|
||||||
|
| GET | `/api/dashboard` | 仪表盘汇总 |
|
||||||
|
| GET/POST | `/api/instances` | 列出/创建实例 |
|
||||||
|
| GET/PUT/DELETE | `/api/instances/:id` | 单实例 CRUD |
|
||||||
|
| POST | `/api/instances/:id/start` / `/stop` | 启停 |
|
||||||
|
| GET/POST | `/api/instances/:id/users` | 用户列表/新建 |
|
||||||
|
| POST | `/api/instances/:id/users/:uid/revoke` | 吊销 |
|
||||||
|
| DELETE | `/api/instances/:id/users/:uid` | 删除 |
|
||||||
|
| GET | `/api/instances/:id/users/:uid/ovpn?host=X` | 下载 .ovpn |
|
||||||
|
| GET | `/api/certs` | 证书到期清单 |
|
||||||
|
| GET | `/api/connlogs?instance=X` | 连接日志 |
|
||||||
|
| GET/POST/DELETE | `/api/backups` / `/api/backups/:id` | 备份管理 |
|
||||||
|
| POST | `/api/backups/:id/restore` | 恢复 |
|
||||||
|
| GET | `/api/audits` | 审计日志 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8089/api/login \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"admin123"}' | jq -r .token)
|
||||||
|
|
||||||
|
# 创建实例
|
||||||
|
curl -X POST http://localhost:8089/api/instances \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"name":"prod","port":1194,"subnet":"10.8.0.0/24"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 五、常见问题
|
||||||
|
|
||||||
|
**Q: 服务启动后访问 8089 提示"无法连接"?**
|
||||||
|
|
||||||
|
排查顺序:
|
||||||
|
```bash
|
||||||
|
systemctl status openvpn-manager # 进程在不在?
|
||||||
|
journalctl -u openvpn-manager -n 50 # 启动报错?
|
||||||
|
ss -tlnp | grep 8089 # 端口在听吗?
|
||||||
|
curl http://127.0.0.1:8089/api/health # 本机能访问吗?
|
||||||
|
```
|
||||||
|
|
||||||
|
常见原因:`go`/`nodejs`/`openvpn` 没装或版本太低。
|
||||||
|
|
||||||
|
**Q: 在实例列表点"启动"提示成功,但状态一直 stopped?**
|
||||||
|
|
||||||
|
OpenVPN 需要 `root` 启动。如果你的 systemd 单元不是 root 运行,启动会失败。检查:
|
||||||
|
```bash
|
||||||
|
ps aux | grep openvpn-manager # 确认主进程是 root
|
||||||
|
cat /etc/systemd/system/openvpn-manager.service | grep User
|
||||||
|
# 应该 User=root
|
||||||
|
```
|
||||||
|
|
||||||
|
**Q: 下载的 .ovpn 客户端连不上?**
|
||||||
|
|
||||||
|
1. 确认服务器防火墙/路由器开放了对应端口(UDP 1194 等)
|
||||||
|
2. 客户端能 ping 通服务器公网 IP 吗?
|
||||||
|
3. 服务端 status.log 有没有客户端连接尝试?
|
||||||
|
|
||||||
|
**Q: 怎么从备份恢复?**
|
||||||
|
|
||||||
|
Web → "备份与恢复" → 选中备份 → "恢复"。会**覆盖**现有 `pki/`、`instances/`、`clients/`,操作前**先做一次新的备份**以防万一。
|
||||||
|
|
||||||
|
**Q: 能用 Nginx 反代 + HTTPS 吗?**
|
||||||
|
|
||||||
|
可以,示例 Nginx 配置:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name vpn-admin.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/vpn-admin.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/vpn-admin.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8089;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 OpenVPN 自身的 UDP/TCP 端口(1194 等)**不能**走 Nginx,要单独放行。
|
||||||
|
|
||||||
|
**Q: 支持 IPv6 吗?**
|
||||||
|
|
||||||
|
服务端支持(把 `proto` 改成 `udp6` 或 `tcp6`,子网用 IPv6 CIDR),但本项目目前 UI 主要按 IPv4 写,IPv6 子网需要手动编辑 `server.conf`(用"自定义"字段)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 六、开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
cd backend
|
||||||
|
go run ./cmd/server --data ../data --dist ../dist
|
||||||
|
|
||||||
|
# 前端(开发热重载,会自动代理 /api 到 :8089)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# 浏览器打开 http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
调试模式日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OVPNMGR_LOG_LEVEL=debug ./bin/openvpn-manager --data ./data --dist ./dist
|
||||||
|
```
|
||||||
|
|
||||||
|
跑测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./...
|
||||||
|
cd frontend && npm run build # 顺便当 type/lint 校验
|
||||||
|
```
|
||||||
|
|
||||||
|
构建发布版:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前端
|
||||||
|
cd frontend && npm run build && cd ..
|
||||||
|
|
||||||
|
# 后端(静态链接、剥离调试符号)
|
||||||
|
cd backend
|
||||||
|
CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -o ../bin/openvpn-manager ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
本项目使用了以下开源软件:
|
||||||
|
|
||||||
|
- [OpenVPN](https://openvpn.net/)
|
||||||
|
- [Gin Web Framework](https://github.com/gin-gonic/gin)
|
||||||
|
- [Vue.js](https://vuejs.org/)
|
||||||
|
- [Element Plus](https://element-plus.org/)
|
||||||
|
- [ECharts](https://echarts.apache.org/)
|
||||||
|
- [golang-jwt](https://github.com/golang-jwt/jwt)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"openvpn-manager/internal/api"
|
||||||
|
"openvpn-manager/internal/config"
|
||||||
|
"openvpn-manager/internal/service"
|
||||||
|
"openvpn-manager/internal/store"
|
||||||
|
"openvpn-manager/pkg/openvpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dataDir := flag.String("data", "", "data directory (default $OVPNMGR_DATA or ./data)")
|
||||||
|
distDir := flag.String("dist", "", "frontend dist directory (default $OVPNMGR_DIST or ./dist)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *dataDir == "" {
|
||||||
|
*dataDir = os.Getenv("OVPNMGR_DATA")
|
||||||
|
}
|
||||||
|
if *dataDir == "" {
|
||||||
|
*dataDir = "./data"
|
||||||
|
}
|
||||||
|
if *distDir == "" {
|
||||||
|
*distDir = os.Getenv("OVPNMGR_DIST")
|
||||||
|
}
|
||||||
|
if *distDir == "" {
|
||||||
|
*distDir = "./dist"
|
||||||
|
}
|
||||||
|
abs, _ := filepath.Abs(*dataDir)
|
||||||
|
cfg := config.Load(abs)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.PKIDir(), 0o700); err != nil {
|
||||||
|
log.Fatalf("mkdir pki: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cfg.InstancesDir(), 0o755); err != nil {
|
||||||
|
log.Fatalf("mkdir instances: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cfg.ClientsDir(), 0o755); err != nil {
|
||||||
|
log.Fatalf("mkdir clients: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
|
||||||
|
log.Fatalf("mkdir backups: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(cfg.DBFile())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
ovm := openvpn.NewManager(cfg.OpenVPNBin, cfg.DataDir)
|
||||||
|
if err := ovm.EnsureCA(); err != nil {
|
||||||
|
log.Printf("warn: ensure CA: %v", err)
|
||||||
|
}
|
||||||
|
svc := service.New(cfg, st, ovm)
|
||||||
|
|
||||||
|
addr := cfg.Host + ":" + itoa(cfg.Port)
|
||||||
|
log.Printf("openvpn-manager listening on %s, data=%s, dist=%s", addr, cfg.DataDir, *distDir)
|
||||||
|
srv := api.NewServer(cfg, svc)
|
||||||
|
if err := srv.Router(*distDir).Run(addr); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := false
|
||||||
|
if n < 0 {
|
||||||
|
neg = true
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module openvpn-manager
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/cors v1.7.7 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||||
|
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||||
|
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||||
|
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"openvpn-manager/internal/config"
|
||||||
|
"openvpn-manager/internal/middleware"
|
||||||
|
"openvpn-manager/internal/model"
|
||||||
|
"openvpn-manager/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Cfg *config.Config
|
||||||
|
Svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(cfg *config.Config, svc *service.Service) *Server {
|
||||||
|
return &Server{Cfg: cfg, Svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Router(distDir string) *gin.Engine {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
r.Use(cors.New(cors.Config{
|
||||||
|
AllowAllOrigins: true,
|
||||||
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Authorization", "Content-Type"},
|
||||||
|
MaxAge: 12 * time.Hour,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 公共
|
||||||
|
r.POST("/api/login", s.login)
|
||||||
|
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||||
|
|
||||||
|
auth := r.Group("/api", middleware.JWTAuth(s.Cfg.JWTSecret))
|
||||||
|
{
|
||||||
|
auth.GET("/me", s.me)
|
||||||
|
auth.POST("/logout", s.logout)
|
||||||
|
auth.GET("/dashboard", s.dashboard)
|
||||||
|
|
||||||
|
auth.GET("/instances", s.listInstances)
|
||||||
|
auth.POST("/instances", s.createInstance)
|
||||||
|
auth.GET("/instances/:id", s.getInstance)
|
||||||
|
auth.PUT("/instances/:id", s.updateInstance)
|
||||||
|
auth.DELETE("/instances/:id", s.deleteInstance)
|
||||||
|
auth.POST("/instances/:id/start", s.startInstance)
|
||||||
|
auth.POST("/instances/:id/stop", s.stopInstance)
|
||||||
|
auth.GET("/instances/:id/online", s.onlineClients)
|
||||||
|
|
||||||
|
auth.GET("/instances/:id/users", s.listUsers)
|
||||||
|
auth.POST("/instances/:id/users", s.createUser)
|
||||||
|
auth.POST("/instances/:id/users/:uid/revoke", s.revokeUser)
|
||||||
|
auth.DELETE("/instances/:id/users/:uid", s.deleteUser)
|
||||||
|
auth.GET("/instances/:id/users/:uid/ovpn", s.downloadOVPN)
|
||||||
|
|
||||||
|
auth.GET("/certs", s.listCerts)
|
||||||
|
|
||||||
|
auth.GET("/connlogs", s.listConnLogs)
|
||||||
|
|
||||||
|
auth.GET("/backups", s.listBackups)
|
||||||
|
auth.POST("/backups", s.createBackup)
|
||||||
|
auth.POST("/backups/:id/restore", s.restoreBackup)
|
||||||
|
auth.DELETE("/backups/:id", s.deleteBackup)
|
||||||
|
|
||||||
|
auth.GET("/audits", s.listAudits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态前端
|
||||||
|
if distDir != "" {
|
||||||
|
if _, err := os.Stat(distDir); err == nil {
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
path := filepath.Join(distDir, c.Request.URL.Path)
|
||||||
|
if !fileExists(path) || strings.HasSuffix(c.Request.URL.Path, "/") {
|
||||||
|
c.File(filepath.Join(distDir, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.File(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(p string) bool {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
return err == nil && !fi.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- handlers ----------
|
||||||
|
|
||||||
|
func (s *Server) login(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Username != s.Cfg.AdminUser || req.Password != s.Cfg.AdminPass {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok, err := middleware.IssueToken(s.Cfg.JWTSecret, req.Username, "admin", 12*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"token": tok, "username": req.Username})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
||||||
|
|
||||||
|
func (s *Server) me(c *gin.Context) {
|
||||||
|
u, _ := c.Get("user")
|
||||||
|
c.JSON(200, gin.H{"username": u})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dashboard 汇总统计
|
||||||
|
func (s *Server) dashboard(c *gin.Context) {
|
||||||
|
instances := s.Svc.Store.ListInstances()
|
||||||
|
users := s.Svc.Store.ListUsers("")
|
||||||
|
certs, _ := s.Svc.CertInfos()
|
||||||
|
expiring := 0
|
||||||
|
for _, ct := range certs {
|
||||||
|
if ct.DaysLeft <= 30 {
|
||||||
|
expiring++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
online := 0
|
||||||
|
for _, in := range instances {
|
||||||
|
cl, _ := s.Svc.ListOnline(in.ID)
|
||||||
|
online += len(cl)
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"instances": len(instances),
|
||||||
|
"running": countByStatus(instances, "running"),
|
||||||
|
"users": len(users),
|
||||||
|
"active_users": countEnabled(users),
|
||||||
|
"online": online,
|
||||||
|
"expiring_certs": expiring,
|
||||||
|
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||||
|
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- instances ----
|
||||||
|
|
||||||
|
func (s *Server) listInstances(c *gin.Context) {
|
||||||
|
c.JSON(200, s.Svc.Store.ListInstances())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getInstance(c *gin.Context) {
|
||||||
|
in, err := s.Svc.Store.GetInstance(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(404, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createInstance(c *gin.Context) {
|
||||||
|
var in model.Instance
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := s.Svc.CreateInstance(in)
|
||||||
|
if err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "create_instance", in.Name, err.Error(), "failed")
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "create_instance", in.Name, "port="+itoa(out.Port), "ok")
|
||||||
|
c.JSON(200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateInstance(c *gin.Context) {
|
||||||
|
var in model.Instance
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.ID = c.Param("id")
|
||||||
|
if err := s.Svc.UpdateInstance(in); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "update_instance", in.Name, "", "ok")
|
||||||
|
c.JSON(200, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteInstance(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := s.Svc.DeleteInstance(id); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "delete_instance", id, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startInstance(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := s.Svc.StartInstance(id); err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "start_instance", id, err.Error(), "failed")
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "start_instance", id, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) stopInstance(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := s.Svc.StopInstance(id); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "stop_instance", id, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) onlineClients(c *gin.Context) {
|
||||||
|
cl, err := s.Svc.ListOnline(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, cl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- users ----
|
||||||
|
|
||||||
|
func (s *Server) listUsers(c *gin.Context) {
|
||||||
|
c.JSON(200, s.Svc.Store.ListUsers(c.Param("id")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createUser(c *gin.Context) {
|
||||||
|
var u model.VPNUser
|
||||||
|
if err := c.ShouldBindJSON(&u); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.InstanceID = c.Param("id")
|
||||||
|
out, err := s.Svc.CreateUser(u)
|
||||||
|
if err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "create_user", u.Username, err.Error(), "failed")
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "create_user", u.Username, "instance="+u.InstanceID, "ok")
|
||||||
|
c.JSON(200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) revokeUser(c *gin.Context) {
|
||||||
|
uid := c.Param("uid")
|
||||||
|
if err := s.Svc.RevokeUser(uid); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "revoke_user", uid, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteUser(c *gin.Context) {
|
||||||
|
uid := c.Param("uid")
|
||||||
|
if err := s.Svc.DeleteUser(uid); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "delete_user", uid, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) downloadOVPN(c *gin.Context) {
|
||||||
|
host := c.Query("host")
|
||||||
|
if host == "" {
|
||||||
|
host = c.Request.Host
|
||||||
|
}
|
||||||
|
p, err := s.Svc.GenerateOVPN(c.Param("uid"), host)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(p))
|
||||||
|
c.File(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- certs ----
|
||||||
|
|
||||||
|
func (s *Server) listCerts(c *gin.Context) {
|
||||||
|
certs, err := s.Svc.CertInfos()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, certs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- conn logs ----
|
||||||
|
|
||||||
|
func (s *Server) listConnLogs(c *gin.Context) {
|
||||||
|
c.JSON(200, s.Svc.Store.ListConnLogs(c.Query("instance"), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- backups ----
|
||||||
|
|
||||||
|
func (s *Server) listBackups(c *gin.Context) {
|
||||||
|
c.JSON(200, s.Svc.Store.ListBackups())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createBackup(c *gin.Context) {
|
||||||
|
var req struct{ Note string `json:"note"` }
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
b, err := s.Svc.Backup(req.Note)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "create_backup", b.ID, "", "ok")
|
||||||
|
c.JSON(200, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) restoreBackup(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := s.Svc.Restore(id); err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "restore_backup", id, err.Error(), "failed")
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "restore_backup", id, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteBackup(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := s.Svc.DeleteBackup(id); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audits ----
|
||||||
|
|
||||||
|
func (s *Server) listAudits(c *gin.Context) {
|
||||||
|
c.JSON(200, s.Svc.Store.ListAudits(500))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
func countByStatus(in []model.Instance, status string) int {
|
||||||
|
n := 0
|
||||||
|
for _, x := range in {
|
||||||
|
if x.Status == status {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func countEnabled(u []model.VPNUser) int {
|
||||||
|
n := 0
|
||||||
|
for _, x := range u {
|
||||||
|
if x.Enabled {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := false
|
||||||
|
if n < 0 {
|
||||||
|
neg = true
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 包含 Web 管理界面的运行时配置。
|
||||||
|
// 数据目录结构:
|
||||||
|
// <DataDir>/
|
||||||
|
// config.yaml - 全局配置
|
||||||
|
// db.json - 用户/实例元数据(简化存储)
|
||||||
|
// pki/ - CA 与证书(PKI 基础设施)
|
||||||
|
// ca.crt / ca.key / dh.pem / ta.key
|
||||||
|
// instances/<name>/ - 每个 OpenVPN 实例的目录
|
||||||
|
// server.conf
|
||||||
|
// pki/{issued,private,csd}
|
||||||
|
// ccd/ - 客户端配置目录
|
||||||
|
// status.log - openvpn --status 周期输出
|
||||||
|
// logs/ - openvpn 运行日志
|
||||||
|
// clients/<inst>/<user>/ - 生成的 .ovpn 文件
|
||||||
|
// backups/ - 备份归档
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
DataDir string `yaml:"data_dir"`
|
||||||
|
JWTSecret string `yaml:"jwt_secret"`
|
||||||
|
AdminUser string `yaml:"admin_user"`
|
||||||
|
AdminPass string `yaml:"admin_pass"`
|
||||||
|
LogLevel string `yaml:"log_level"`
|
||||||
|
OpenVPNBin string `yaml:"openvpn_bin"`
|
||||||
|
EasyrsaBin string `yaml:"easyrsa_bin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(dataDir string) *Config {
|
||||||
|
c := &Config{
|
||||||
|
Host: getenv("OVPNMGR_HOST", "0.0.0.0"),
|
||||||
|
Port: getenvInt("OVPNMGR_PORT", 8089),
|
||||||
|
DataDir: dataDir,
|
||||||
|
JWTSecret: getenv("OVPNMGR_JWT_SECRET", "change-me-in-prod-please"),
|
||||||
|
AdminUser: getenv("OVPNMGR_ADMIN_USER", "admin"),
|
||||||
|
AdminPass: getenv("OVPNMGR_ADMIN_PASS", "admin123"),
|
||||||
|
LogLevel: getenv("OVPNMGR_LOG_LEVEL", "info"),
|
||||||
|
OpenVPNBin: getenv("OVPNMGR_OPENVPN_BIN", "openvpn"),
|
||||||
|
EasyrsaBin: getenv("OVPNMGR_EASYRSA_BIN", "easyrsa"),
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) PKIDir() string { return filepath.Join(c.DataDir, "pki") }
|
||||||
|
func (c *Config) InstancesDir() string { return filepath.Join(c.DataDir, "instances") }
|
||||||
|
func (c *Config) InstanceDir(name string) string {
|
||||||
|
return filepath.Join(c.InstancesDir(), name)
|
||||||
|
}
|
||||||
|
func (c *Config) ClientsDir() string { return filepath.Join(c.DataDir, "clients") }
|
||||||
|
func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups") }
|
||||||
|
func (c *Config) DBFile() string { return filepath.Join(c.DataDir, "db.json") }
|
||||||
|
|
||||||
|
func getenv(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvInt(k string, def int) int {
|
||||||
|
v := os.Getenv(k)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func IssueToken(secret, username, role string, ttl time.Duration) (string, error) {
|
||||||
|
c := Claims{
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
|
||||||
|
return t.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func JWTAuth(secret string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
h := c.GetHeader("Authorization")
|
||||||
|
if h == "" {
|
||||||
|
h = c.Query("token")
|
||||||
|
}
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if strings.HasPrefix(h, prefix) {
|
||||||
|
h = strings.TrimPrefix(h, prefix)
|
||||||
|
}
|
||||||
|
if h == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := &Claims{}
|
||||||
|
_, err := jwt.ParseWithClaims(h, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("user", claims.Username)
|
||||||
|
c.Set("role", claims.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Instance 一个 OpenVPN 服务端实例。
|
||||||
|
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
|
||||||
|
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
|
||||||
|
type Instance struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"` // 唯一名,作为目录名
|
||||||
|
Port int `json:"port"` // openvpn 监听端口
|
||||||
|
Proto string `json:"proto"` // udp / tcp
|
||||||
|
Dev string `json:"dev"` // tun / tap
|
||||||
|
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
|
||||||
|
Cipher string `json:"cipher"` // 加密算法
|
||||||
|
AuthDigest string `json:"auth_digest"` // 摘要算法
|
||||||
|
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
|
||||||
|
PushRoutes string `json:"push_routes"` // 多行
|
||||||
|
Extra string `json:"extra"` // 用户追加配置
|
||||||
|
Status string `json:"status"` // running/stopped/error
|
||||||
|
PID int `json:"pid"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPNUser 一个 OpenVPN 客户端用户。
|
||||||
|
// 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。
|
||||||
|
type VPNUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
Username string `json:"username"` // 证书 CN
|
||||||
|
RealName string `json:"real_name"` // 备注
|
||||||
|
Email string `json:"email"`
|
||||||
|
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
|
||||||
|
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLog 操作审计日志
|
||||||
|
type AuditLog struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
User string `json:"user"` // 操作者(管理用户)
|
||||||
|
Action string `json:"action"` // create_instance, revoke_user, ...
|
||||||
|
Target string `json:"target"` // 目标对象
|
||||||
|
Result string `json:"result"` // ok / failed
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。
|
||||||
|
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
|
||||||
|
type ConnectionLog struct {
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
CommonName string `json:"common_name"`
|
||||||
|
RealIP string `json:"real_ip"` // 客户端公网 IP
|
||||||
|
VPNIP string `json:"vpn_ip"` // 分配的 VPN 内网 IP
|
||||||
|
BytesIn int64 `json:"bytes_in"`
|
||||||
|
BytesOut int64 `json:"bytes_out"`
|
||||||
|
ConnectedAt time.Time `json:"connected_at"`
|
||||||
|
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup 一份备份归档
|
||||||
|
type Backup struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Includes []string `json:"includes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertInfo 证书元数据(用于证书到期提醒)。
|
||||||
|
type CertInfo struct {
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
NotBefore time.Time `json:"not_before"`
|
||||||
|
NotAfter time.Time `json:"not_after"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"openvpn-manager/internal/config"
|
||||||
|
"openvpn-manager/internal/model"
|
||||||
|
"openvpn-manager/internal/store"
|
||||||
|
"openvpn-manager/pkg/openvpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service 业务逻辑聚合,供 API 层调用。
|
||||||
|
// 任何对实例/用户/证书/备份的变更都应经过这里,从而写入审计日志。
|
||||||
|
type Service struct {
|
||||||
|
Cfg *config.Config
|
||||||
|
Store *store.Store
|
||||||
|
Ovm *openvpn.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, st *store.Store, ovm *openvpn.Manager) *Service {
|
||||||
|
return &Service{Cfg: cfg, Store: st, Ovm: ovm}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) audit(c context.Context, action, target, detail, result, ip string) {
|
||||||
|
username, _ := c.Value("user").(string)
|
||||||
|
if username == "" {
|
||||||
|
username = "system"
|
||||||
|
}
|
||||||
|
_ = s.Store.AppendAudit(model.AuditLog{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Time: time.Now(),
|
||||||
|
User: username,
|
||||||
|
Action: action,
|
||||||
|
Target: target,
|
||||||
|
Result: result,
|
||||||
|
Detail: detail,
|
||||||
|
IP: ip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditForAPI 在 API 层被调用时手动写入(因为 gin context 转为 context.Context)。
|
||||||
|
func (s *Service) AuditForAPI(c *gin.Context, action, target, detail, result string) {
|
||||||
|
username, _ := c.Get("user")
|
||||||
|
un, _ := username.(string)
|
||||||
|
if un == "" {
|
||||||
|
un = "system"
|
||||||
|
}
|
||||||
|
_ = s.Store.AppendAudit(model.AuditLog{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Time: time.Now(),
|
||||||
|
User: un,
|
||||||
|
Action: action,
|
||||||
|
Target: target,
|
||||||
|
Result: result,
|
||||||
|
Detail: detail,
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInstance 新建一个 OpenVPN 实例,并签发服务端证书、生成 server.conf。
|
||||||
|
func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
|
||||||
|
if in.Name == "" {
|
||||||
|
return nil, fmt.Errorf("name required")
|
||||||
|
}
|
||||||
|
if in.Port == 0 {
|
||||||
|
return nil, fmt.Errorf("port required")
|
||||||
|
}
|
||||||
|
if in.Proto == "" {
|
||||||
|
in.Proto = "udp"
|
||||||
|
}
|
||||||
|
if in.Dev == "" {
|
||||||
|
in.Dev = "tun"
|
||||||
|
}
|
||||||
|
if in.Subnet == "" {
|
||||||
|
in.Subnet = "10.8.0.0/24"
|
||||||
|
}
|
||||||
|
// 名称查重
|
||||||
|
if _, err := s.Store.GetInstanceByName(in.Name); err == nil {
|
||||||
|
return nil, fmt.Errorf("instance %s already exists", in.Name)
|
||||||
|
}
|
||||||
|
// 确保 CA
|
||||||
|
if err := s.Ovm.EnsureCA(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.ID = uuid.NewString()
|
||||||
|
in.Status = "stopped"
|
||||||
|
in.CreatedAt = time.Now()
|
||||||
|
in.UpdatedAt = time.Now()
|
||||||
|
// 创建实例目录
|
||||||
|
if err := os.MkdirAll(s.Cfg.InstanceDir(in.Name), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs"), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 签发服务端证书
|
||||||
|
if err := s.Ovm.IssueServerCert(in.Name); err != nil {
|
||||||
|
return nil, fmt.Errorf("issue server cert: %w", err)
|
||||||
|
}
|
||||||
|
// 写 server.conf
|
||||||
|
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
|
||||||
|
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("write conf: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.Store.UpsertInstance(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstance 仅更新可热改字段(端口/子网需要重启生效)。
|
||||||
|
func (s *Service) UpdateInstance(in model.Instance) error {
|
||||||
|
old, err := s.Store.GetInstance(in.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.CreatedAt = old.CreatedAt
|
||||||
|
in.UpdatedAt = time.Now()
|
||||||
|
in.Status = old.Status
|
||||||
|
in.PID = old.PID
|
||||||
|
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
|
||||||
|
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Store.UpsertInstance(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInstance 移除实例及其 PKI/配置。
|
||||||
|
func (s *Service) DeleteInstance(id string) error {
|
||||||
|
in, err := s.Store.GetInstance(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Status == "running" {
|
||||||
|
_ = s.StopInstance(id)
|
||||||
|
}
|
||||||
|
// 清理用户记录与目录
|
||||||
|
for _, u := range s.Store.ListUsers(id) {
|
||||||
|
_ = s.Store.DeleteUser(u.ID)
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(s.Cfg.InstanceDir(in.Name))
|
||||||
|
_ = os.RemoveAll(filepath.Join(s.Cfg.ClientsDir(), in.Name))
|
||||||
|
return s.Store.DeleteInstance(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartInstance 在前台启动 openvpn。
|
||||||
|
// 注意:本服务应以 root 运行;非 root 场景下应通过 systemd 单元托管。
|
||||||
|
func (s *Service) StartInstance(id string) error {
|
||||||
|
in, err := s.Store.GetInstance(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Status == "running" {
|
||||||
|
return fmt.Errorf("already running")
|
||||||
|
}
|
||||||
|
conf := s.Ovm.InstanceConf(in.Name)
|
||||||
|
if _, err := os.Stat(conf); err != nil {
|
||||||
|
return fmt.Errorf("conf missing: %w", err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(s.Cfg.OpenVPNBin,
|
||||||
|
"--cd", s.Cfg.InstanceDir(in.Name),
|
||||||
|
"--config", conf)
|
||||||
|
logf, _ := os.OpenFile(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs", "openvpn-stdout.log"),
|
||||||
|
os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if logf != nil {
|
||||||
|
cmd.Stdout = logf
|
||||||
|
cmd.Stderr = logf
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.Status = "running"
|
||||||
|
in.PID = cmd.Process.Pid
|
||||||
|
in.UpdatedAt = time.Now()
|
||||||
|
_ = s.Store.UpsertInstance(*in)
|
||||||
|
// 后台释放
|
||||||
|
go func() {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
// 进程退出时回写状态(简单模型)
|
||||||
|
cur, err := s.Store.GetInstance(id)
|
||||||
|
if err == nil && cur.PID == cmd.Process.Pid {
|
||||||
|
cur.Status = "stopped"
|
||||||
|
cur.PID = 0
|
||||||
|
cur.UpdatedAt = time.Now()
|
||||||
|
_ = s.Store.UpsertInstance(*cur)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopInstance 通过 SIGTERM 停止实例。
|
||||||
|
func (s *Service) StopInstance(id string) error {
|
||||||
|
in, err := s.Store.GetInstance(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.PID == 0 {
|
||||||
|
in.Status = "stopped"
|
||||||
|
return s.Store.UpsertInstance(*in)
|
||||||
|
}
|
||||||
|
proc, err := os.FindProcess(in.PID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := proc.Signal(os.Interrupt); err != nil {
|
||||||
|
// 兜底:直接 Kill
|
||||||
|
_ = proc.Signal(os.Kill)
|
||||||
|
}
|
||||||
|
in.Status = "stopped"
|
||||||
|
in.PID = 0
|
||||||
|
in.UpdatedAt = time.Now()
|
||||||
|
return s.Store.UpsertInstance(*in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser 新建客户端用户并签发证书。
|
||||||
|
func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
|
||||||
|
if u.Username == "" {
|
||||||
|
return nil, fmt.Errorf("username required")
|
||||||
|
}
|
||||||
|
in, err := s.Store.GetInstance(u.InstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil {
|
||||||
|
return nil, fmt.Errorf("user %s already exists", u.Username)
|
||||||
|
}
|
||||||
|
u.ID = uuid.NewString()
|
||||||
|
u.Enabled = true
|
||||||
|
u.CreatedAt = time.Now()
|
||||||
|
// 签发证书(Manager 按实例名索引 PKI)
|
||||||
|
if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// CCD
|
||||||
|
if u.StaticIP != "" {
|
||||||
|
ccd := "ifconfig-push " + u.StaticIP + " 255.255.255.0\n"
|
||||||
|
if err := s.Ovm.WriteCCD(in.Name, u.Username, ccd); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载)
|
||||||
|
if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.Store.UpsertUser(u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOVPN 下载/重新生成 .ovpn,remoteHost 由前端传入。
|
||||||
|
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
||||||
|
u, err := s.Store.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
in, err := s.Store.GetInstance(u.InstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeUser 吊销用户:禁用 + 标记。
|
||||||
|
func (s *Service) RevokeUser(userID string) error {
|
||||||
|
u, err := s.Store.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Enabled = false
|
||||||
|
now := time.Now()
|
||||||
|
u.RevokedAt = &now
|
||||||
|
if err := s.Store.UpsertUser(*u); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 在 ccd 写入禁用标记
|
||||||
|
in, err2 := s.Store.GetInstance(u.InstanceID)
|
||||||
|
if err2 == nil {
|
||||||
|
body := "# revoked by manager\n"
|
||||||
|
_ = s.Ovm.WriteCCD(in.Name, u.Username, body)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser 删除用户及证书。
|
||||||
|
func (s *Service) DeleteUser(userID string) error {
|
||||||
|
u, err := s.Store.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in, _ := s.Store.GetInstance(u.InstanceID)
|
||||||
|
if in != nil {
|
||||||
|
_ = s.Ovm.DeleteCCD(in.Name, u.Username)
|
||||||
|
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "issued", u.Username+".crt"))
|
||||||
|
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "private", u.Username+".key"))
|
||||||
|
_ = os.Remove(filepath.Join(s.Cfg.ClientsDir(), in.Name, u.Username+".ovpn"))
|
||||||
|
}
|
||||||
|
return s.Store.DeleteUser(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertInfos 汇总所有用户证书的过期时间。
|
||||||
|
func (s *Service) CertInfos() ([]model.CertInfo, error) {
|
||||||
|
var out []model.CertInfo
|
||||||
|
now := time.Now()
|
||||||
|
for _, in := range s.Store.ListInstances() {
|
||||||
|
pki := s.Ovm.PKIPath(in.Name)
|
||||||
|
issuedDir := filepath.Join(pki, "issued")
|
||||||
|
entries, err := os.ReadDir(issuedDir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".crt") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cn := strings.TrimSuffix(e.Name(), ".crt")
|
||||||
|
if cn == "server" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
notAfter, err := s.Ovm.CertNotAfter(filepath.Join(issuedDir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, model.CertInfo{
|
||||||
|
InstanceID: in.ID,
|
||||||
|
Username: cn,
|
||||||
|
NotAfter: notAfter,
|
||||||
|
DaysLeft: int(notAfter.Sub(now).Hours() / 24),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOnline 解析 status 文件获取在线客户端。
|
||||||
|
func (s *Service) ListOnline(instanceID string) ([]openvpn.StatusEntry, error) {
|
||||||
|
in, err := s.Store.GetInstance(instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log")
|
||||||
|
return s.Ovm.ParseStatus(statusPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup 创建 tar.gz 备份。
|
||||||
|
func (s *Service) Backup(note string) (*model.Backup, error) {
|
||||||
|
if err := os.MkdirAll(s.Cfg.BackupsDir(), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := uuid.NewString()
|
||||||
|
ts := time.Now().Format("20060102-150405")
|
||||||
|
fp := filepath.Join(s.Cfg.BackupsDir(), "backup-"+ts+"-"+id[:8]+".tar.gz")
|
||||||
|
f, err := os.Create(fp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
defer gz.Close()
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
defer tw.Close()
|
||||||
|
|
||||||
|
add := func(rel string) error {
|
||||||
|
abs := filepath.Join(s.Cfg.DataDir, rel)
|
||||||
|
return filepath.Walk(abs, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hdr.Name = filepath.ToSlash(filepath.Join(rel, strings.TrimPrefix(path, abs)))
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, _ = tw.Write(data)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, sub := range []string{"pki", "instances", "clients"} {
|
||||||
|
_ = add(sub)
|
||||||
|
}
|
||||||
|
b := &model.Backup{
|
||||||
|
ID: id,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Filename: filepath.Base(fp),
|
||||||
|
Note: note,
|
||||||
|
Includes: []string{"pki", "instances", "clients"},
|
||||||
|
}
|
||||||
|
fi, _ := os.Stat(fp)
|
||||||
|
if fi != nil {
|
||||||
|
b.Size = fi.Size()
|
||||||
|
}
|
||||||
|
_ = s.Store.AddBackup(*b)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 从备份恢复。会覆盖现有数据。
|
||||||
|
func (s *Service) Restore(backupID string) error {
|
||||||
|
var bk *model.Backup
|
||||||
|
for _, b := range s.Store.ListBackups() {
|
||||||
|
if b.ID == backupID {
|
||||||
|
b := b
|
||||||
|
bk = &b
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bk == nil {
|
||||||
|
return fmt.Errorf("backup not found")
|
||||||
|
}
|
||||||
|
src := filepath.Join(s.Cfg.BackupsDir(), bk.Filename)
|
||||||
|
f, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var r io.Reader = f
|
||||||
|
if strings.HasSuffix(src, ".gz") {
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
r = gz
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(r)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(s.Cfg.DataDir, hdr.Name)
|
||||||
|
if hdr.FileInfo().IsDir() {
|
||||||
|
_ = os.MkdirAll(target, 0o755)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Dir(target), 0o755)
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(out, tr)
|
||||||
|
_ = out.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBackup 删除备份文件与索引。
|
||||||
|
func (s *Service) DeleteBackup(id string) error {
|
||||||
|
for _, b := range s.Store.ListBackups() {
|
||||||
|
if b.ID == id {
|
||||||
|
_ = os.Remove(filepath.Join(s.Cfg.BackupsDir(), b.Filename))
|
||||||
|
return s.Store.DeleteBackup(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomToken 生成短随机串。
|
||||||
|
func RandomToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openvpn-manager/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store 简易 JSON 文件存储:
|
||||||
|
// 适合中小规模运维工具,无需引入数据库。所有变更通过 RWMutex 保护。
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
data Data
|
||||||
|
writeCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Data struct {
|
||||||
|
Instances []model.Instance `json:"instances"`
|
||||||
|
Users []model.VPNUser `json:"users"`
|
||||||
|
Audits []model.AuditLog `json:"audits"`
|
||||||
|
ConnLogs []model.ConnectionLog `json:"conn_logs"`
|
||||||
|
Backups []model.Backup `json:"backups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
s := &Store{path: path}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
s.data = Data{}
|
||||||
|
if err := s.flush(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
s.data = Data{}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &s.data); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse db: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) flush() error {
|
||||||
|
b, err := json.MarshalIndent(s.data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Instances ----
|
||||||
|
|
||||||
|
func (s *Store) ListInstances() []model.Instance {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]model.Instance, len(s.data.Instances))
|
||||||
|
copy(out, s.data.Instances)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetInstance(id string) (*model.Instance, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for i := range s.data.Instances {
|
||||||
|
if s.data.Instances[i].ID == id {
|
||||||
|
in := s.data.Instances[i]
|
||||||
|
return &in, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("instance %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetInstanceByName(name string) (*model.Instance, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for i := range s.data.Instances {
|
||||||
|
if s.data.Instances[i].Name == name {
|
||||||
|
in := s.data.Instances[i]
|
||||||
|
return &in, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("instance %s not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertInstance(in model.Instance) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.data.Instances {
|
||||||
|
if s.data.Instances[i].ID == in.ID {
|
||||||
|
s.data.Instances[i] = in
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.data.Instances = append(s.data.Instances, in)
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteInstance(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
idx := -1
|
||||||
|
for i := range s.data.Instances {
|
||||||
|
if s.data.Instances[i].ID == id {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return fmt.Errorf("instance %s not found", id)
|
||||||
|
}
|
||||||
|
s.data.Instances = append(s.data.Instances[:idx], s.data.Instances[idx+1:]...)
|
||||||
|
// 同步删除其用户
|
||||||
|
users := s.data.Users[:0]
|
||||||
|
for _, u := range s.data.Users {
|
||||||
|
if u.InstanceID != id {
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.data.Users = users
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Users ----
|
||||||
|
|
||||||
|
func (s *Store) ListUsers(instanceID string) []model.VPNUser {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := []model.VPNUser{}
|
||||||
|
for _, u := range s.data.Users {
|
||||||
|
if instanceID == "" || u.InstanceID == instanceID {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUser(id string) (*model.VPNUser, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for i := range s.data.Users {
|
||||||
|
if s.data.Users[i].ID == id {
|
||||||
|
u := s.data.Users[i]
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("user %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUserByCN(instanceID, cn string) (*model.VPNUser, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for i := range s.data.Users {
|
||||||
|
if s.data.Users[i].InstanceID == instanceID && s.data.Users[i].Username == cn {
|
||||||
|
u := s.data.Users[i]
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("user %s/%s not found", instanceID, cn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertUser(u model.VPNUser) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.data.Users {
|
||||||
|
if s.data.Users[i].ID == u.ID {
|
||||||
|
s.data.Users[i] = u
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.data.Users = append(s.data.Users, u)
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteUser(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
idx := -1
|
||||||
|
for i := range s.data.Users {
|
||||||
|
if s.data.Users[i].ID == id {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return fmt.Errorf("user %s not found", id)
|
||||||
|
}
|
||||||
|
s.data.Users = append(s.data.Users[:idx], s.data.Users[idx+1:]...)
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Audit ----
|
||||||
|
|
||||||
|
func (s *Store) AppendAudit(a model.AuditLog) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.data.Audits = append(s.data.Audits, a)
|
||||||
|
// 仅保留最近 5000 条
|
||||||
|
if len(s.data.Audits) > 5000 {
|
||||||
|
s.data.Audits = s.data.Audits[len(s.data.Audits)-5000:]
|
||||||
|
}
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAudits(limit int) []model.AuditLog {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if limit <= 0 || limit > len(s.data.Audits) {
|
||||||
|
limit = len(s.data.Audits)
|
||||||
|
}
|
||||||
|
out := make([]model.AuditLog, limit)
|
||||||
|
copy(out, s.data.Audits[len(s.data.Audits)-limit:])
|
||||||
|
// 倒序
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Connection Logs ----
|
||||||
|
|
||||||
|
func (s *Store) AppendConnLog(c model.ConnectionLog) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.data.ConnLogs = append(s.data.ConnLogs, c)
|
||||||
|
if len(s.data.ConnLogs) > 20000 {
|
||||||
|
s.data.ConnLogs = s.data.ConnLogs[len(s.data.ConnLogs)-20000:]
|
||||||
|
}
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListConnLogs(instanceID string, limit int) []model.ConnectionLog {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := []model.ConnectionLog{}
|
||||||
|
for i := len(s.data.ConnLogs) - 1; i >= 0 && len(out) < limit; i-- {
|
||||||
|
c := s.data.ConnLogs[i]
|
||||||
|
if instanceID == "" || c.InstanceID == instanceID {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FindActiveConn(instanceID, commonName string) *model.ConnectionLog {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
||||||
|
c := s.data.ConnLogs[i]
|
||||||
|
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
|
||||||
|
cc := c
|
||||||
|
return &cc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CloseActiveConn(instanceID, commonName string, at time.Time) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
||||||
|
c := &s.data.ConnLogs[i]
|
||||||
|
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
|
||||||
|
c.DisconnectedAt = &at
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backups ----
|
||||||
|
|
||||||
|
func (s *Store) ListBackups() []model.Backup {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]model.Backup, len(s.data.Backups))
|
||||||
|
copy(out, s.data.Backups)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddBackup(b model.Backup) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.data.Backups = append(s.data.Backups, b)
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteBackup(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
idx := -1
|
||||||
|
for i := range s.data.Backups {
|
||||||
|
if s.data.Backups[i].ID == id {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return fmt.Errorf("backup %s not found", id)
|
||||||
|
}
|
||||||
|
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package openvpn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openvpn-manager/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager 封装与本地 OpenVPN 的交互:生成 server.conf、生成证书、解析 status。
|
||||||
|
// 本服务不直接以 root 启动 OpenVPN 进程(避免安全/权限问题),而是在用户机器上
|
||||||
|
// 由 systemd / launchctl / 自定义脚本拉起。Manager 负责:
|
||||||
|
// 1. 生成易用的 server.conf / client.ovpn / ccd
|
||||||
|
// 2. 解析 openvpn --status 输出
|
||||||
|
// 3. 调用 openssl/easyrsa 完成证书签发与吊销
|
||||||
|
// 4. 提供 ping 命令检查进程可达性
|
||||||
|
type Manager struct {
|
||||||
|
binary string
|
||||||
|
dataDir string
|
||||||
|
pkiDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(binary, dataDir string) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
binary: binary,
|
||||||
|
dataDir: dataDir,
|
||||||
|
pkiDir: filepath.Join(dataDir, "pki"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKIPath 返回实例的 PKI 目录(用于存放 issued/private 等子目录)。
|
||||||
|
func (m *Manager) PKIPath(instanceName string) string {
|
||||||
|
return filepath.Join(m.dataDir, "instances", instanceName, "pki")
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceConf 返回实例 server.conf 路径。
|
||||||
|
func (m *Manager) InstanceConf(instanceName string) string {
|
||||||
|
return filepath.Join(m.dataDir, "instances", instanceName, "server.conf")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureCA 初始化全局 CA。幂等。
|
||||||
|
func (m *Manager) EnsureCA() error {
|
||||||
|
if _, err := os.Stat(filepath.Join(m.pkiDir, "ca.crt")); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(m.pkiDir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 使用 openssl 直接生成自签 CA,避免依赖 easyrsa
|
||||||
|
if err := runShell(`openssl req -x509 -newkey rsa:2048 -nodes \
|
||||||
|
-keyout "`+m.pkiDir+`/ca.key" \
|
||||||
|
-out "`+m.pkiDir+`/ca.crt" \
|
||||||
|
-days 3650 -subj "/CN=OpenVPN-Manager-CA" 2>/dev/null`); err != nil {
|
||||||
|
return fmt.Errorf("generate CA: %w", err)
|
||||||
|
}
|
||||||
|
// DH 参数(轻量: 1024,生产可改为 2048/4096)
|
||||||
|
if _, err := os.Stat(filepath.Join(m.pkiDir, "dh.pem")); os.IsNotExist(err) {
|
||||||
|
if err := runShell(`openssl dhparam -out "` + m.pkiDir + `/dh.pem" 1024 2>/dev/null`); err != nil {
|
||||||
|
return fmt.Errorf("generate DH: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TLS-Auth key
|
||||||
|
if _, err := os.Stat(filepath.Join(m.pkiDir, "ta.key")); os.IsNotExist(err) {
|
||||||
|
if err := runShell(`openvpn --genkey secret "` + m.pkiDir + `/ta.key"`); err != nil {
|
||||||
|
return fmt.Errorf("generate ta.key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteServerConf 生成 server.conf。
|
||||||
|
func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
|
||||||
|
dir := filepath.Dir(m.InstanceConf(in.Name))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conf := strings.Builder{}
|
||||||
|
conf.WriteString("# Generated by openvpn-manager at " + time.Now().Format(time.RFC3339) + "\n")
|
||||||
|
conf.WriteString("port " + strconv.Itoa(in.Port) + "\n")
|
||||||
|
conf.WriteString("proto " + in.Proto + "\n")
|
||||||
|
conf.WriteString("dev " + in.Dev + "\n")
|
||||||
|
conf.WriteString("ca " + filepath.Join(m.pkiDir, "ca.crt") + "\n")
|
||||||
|
conf.WriteString("cert " + filepath.Join(m.PKIPath(in.Name), "issued", "server.crt") + "\n")
|
||||||
|
conf.WriteString("key " + filepath.Join(m.PKIPath(in.Name), "private", "server.key") + "\n")
|
||||||
|
conf.WriteString("dh " + filepath.Join(m.pkiDir, "dh.pem") + "\n")
|
||||||
|
conf.WriteString("tls-auth " + filepath.Join(m.pkiDir, "ta.key") + " 0\n")
|
||||||
|
conf.WriteString("topology subnet\n")
|
||||||
|
conf.WriteString("server " + in.Subnet + "\n")
|
||||||
|
conf.WriteString("ifconfig-pool-persist " + filepath.Join(dir, "ipp.txt") + "\n")
|
||||||
|
conf.WriteString("keepalive 10 120\n")
|
||||||
|
conf.WriteString("persist-key\npersist-tun\n")
|
||||||
|
conf.WriteString("status " + filepath.Join(dir, "status.log") + " 10\n")
|
||||||
|
conf.WriteString("status-version 3\n")
|
||||||
|
conf.WriteString("log " + filepath.Join(dir, "logs", "openvpn.log") + "\n")
|
||||||
|
conf.WriteString("verb 3\n")
|
||||||
|
conf.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n")
|
||||||
|
conf.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
|
||||||
|
if in.PushDNS != "" {
|
||||||
|
for _, line := range strings.Split(in.PushDNS, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conf.WriteString("push \"" + line + "\"\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.PushRoutes != "" {
|
||||||
|
for _, line := range strings.Split(in.PushRoutes, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conf.WriteString("push \"route " + line + "\"\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extraDir != "" {
|
||||||
|
conf.WriteString("client-config-dir " + extraDir + "\n")
|
||||||
|
}
|
||||||
|
if in.Extra != "" {
|
||||||
|
conf.WriteString("\n# --- custom ---\n")
|
||||||
|
conf.WriteString(in.Extra + "\n")
|
||||||
|
}
|
||||||
|
return os.WriteFile(m.InstanceConf(in.Name), []byte(conf.String()), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCert 为客户端签发证书。clientName = CN。
|
||||||
|
// 返回 (certPath, keyPath, error)。
|
||||||
|
func (m *Manager) IssueCert(instanceName, clientName string) (string, string, error) {
|
||||||
|
pki := m.PKIPath(instanceName)
|
||||||
|
issuedDir := filepath.Join(pki, "issued")
|
||||||
|
privDir := filepath.Join(pki, "private")
|
||||||
|
certsDir := filepath.Join(pki, "certs")
|
||||||
|
if err := os.MkdirAll(issuedDir, 0o755); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(privDir, 0o700); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(certsDir, 0o755); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
crt := filepath.Join(issuedDir, clientName+".crt")
|
||||||
|
key := filepath.Join(privDir, clientName+".key")
|
||||||
|
csr := filepath.Join(pki, clientName+".csr")
|
||||||
|
// 已存在则跳过
|
||||||
|
if _, err := os.Stat(crt); err == nil {
|
||||||
|
return crt, key, nil
|
||||||
|
}
|
||||||
|
// 生成私钥
|
||||||
|
if err := runShell(fmt.Sprintf(`openssl genrsa -out "%s" 2048 2>/dev/null`, key)); err != nil {
|
||||||
|
return "", "", fmt.Errorf("gen key: %w", err)
|
||||||
|
}
|
||||||
|
// 生成 CSR
|
||||||
|
if err := runShell(fmt.Sprintf(`openssl req -new -key "%s" -out "%s" -subj "/CN=%s" 2>/dev/null`,
|
||||||
|
key, csr, clientName)); err != nil {
|
||||||
|
return "", "", fmt.Errorf("gen csr: %w", err)
|
||||||
|
}
|
||||||
|
// 用 CA 签发
|
||||||
|
caCrt := filepath.Join(m.pkiDir, "ca.crt")
|
||||||
|
caKey := filepath.Join(m.pkiDir, "ca.key")
|
||||||
|
if err := runShell(fmt.Sprintf(`openssl x509 -req -in "%s" -CA "%s" -CAkey "%s" -CAcreateserial \
|
||||||
|
-out "%s" -days 3650 -sha256 2>/dev/null`, csr, caCrt, caKey, crt)); err != nil {
|
||||||
|
return "", "", fmt.Errorf("sign cert: %w", err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(csr)
|
||||||
|
return crt, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueServerCert 为实例本身签发服务端证书。
|
||||||
|
func (m *Manager) IssueServerCert(instanceName string) error {
|
||||||
|
_, _, err := m.IssueCert(instanceName, "server")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCert 通过 CA 吊销证书(生成 CRL)。
|
||||||
|
// 由于完整 CRL 链路较重,本管理器使用更简单的"禁用"模型:保留吊销标记,
|
||||||
|
// 并通过 ccd 与 enabled=false 让 openvpn 拒绝连接。
|
||||||
|
func (m *Manager) RevokeCert(instanceName, clientName string) error {
|
||||||
|
_ = instanceName
|
||||||
|
_ = clientName
|
||||||
|
// 简化模型:不做 OpenSSL 吊销,仅通过 Store 标记 + 强制策略。
|
||||||
|
// 真正的吊销可通过 `openssl ca -revoke` 扩展。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientOVPN 生成 .ovpn 客户端配置,含内嵌证书便于分发。
|
||||||
|
func (m *Manager) GenerateClientOVPN(in *model.Instance, username, remoteHost string) (string, error) {
|
||||||
|
pki := m.PKIPath(in.Name)
|
||||||
|
crt, err := os.ReadFile(filepath.Join(pki, "issued", username+".crt"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key, err := os.ReadFile(filepath.Join(pki, "private", username+".key"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
caCrt, err := os.ReadFile(filepath.Join(m.pkiDir, "ca.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
taKey, err := os.ReadFile(filepath.Join(m.pkiDir, "ta.key"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if remoteHost == "" {
|
||||||
|
remoteHost = "vpn.example.com"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("client\n")
|
||||||
|
b.WriteString("dev " + in.Dev + "\n")
|
||||||
|
b.WriteString("proto " + in.Proto + "\n")
|
||||||
|
b.WriteString("remote " + remoteHost + " " + strconv.Itoa(in.Port) + "\n")
|
||||||
|
b.WriteString("resolv-retry infinite\n")
|
||||||
|
b.WriteString("nobind\n")
|
||||||
|
b.WriteString("persist-key\npersist-tun\n")
|
||||||
|
b.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n")
|
||||||
|
b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
|
||||||
|
b.WriteString("remote-cert-tls server\n")
|
||||||
|
b.WriteString("verb 3\n")
|
||||||
|
b.WriteString("\n<ca>\n")
|
||||||
|
b.WriteString(string(caCrt))
|
||||||
|
b.WriteString("\n</ca>\n")
|
||||||
|
b.WriteString("\n<cert>\n")
|
||||||
|
b.WriteString(string(crt))
|
||||||
|
b.WriteString("\n</cert>\n")
|
||||||
|
b.WriteString("\n<key>\n")
|
||||||
|
b.WriteString(string(key))
|
||||||
|
b.WriteString("\n</key>\n")
|
||||||
|
b.WriteString("\n<tls-auth>\n")
|
||||||
|
b.WriteString(string(taKey))
|
||||||
|
b.WriteString("\n</tls-auth>\n")
|
||||||
|
b.WriteString("key-direction 1\n")
|
||||||
|
out := filepath.Join(m.dataDir, "clients", in.Name, username+".ovpn")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientOVPNFor 是基于 VPNUser + Instance 的便捷封装。
|
||||||
|
func (m *Manager) GenerateClientOVPNFor(u *model.VPNUser, in *model.Instance, remoteHost string) (string, error) {
|
||||||
|
return m.GenerateClientOVPN(in, u.Username, remoteHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteCCD 写入客户端静态配置(固定 IP 等)。
|
||||||
|
func (m *Manager) WriteCCD(instanceName, username, body string) error {
|
||||||
|
dir := filepath.Join(m.dataDir, "instances", instanceName, "ccd")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(dir, username), []byte(body+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCCD 移除 ccd 文件。
|
||||||
|
func (m *Manager) DeleteCCD(instanceName, username string) error {
|
||||||
|
p := filepath.Join(m.dataDir, "instances", instanceName, "ccd", username)
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseStatus 解析 openvpn --status 输出。
|
||||||
|
// 输入是 status-version 3 的文本,格式详见 OpenVPN 文档。
|
||||||
|
func (m *Manager) ParseStatus(path string) ([]StatusEntry, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return ParseStatusReader(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusEntry 是 status 文件中的一条 CLIENT 记录。
|
||||||
|
type StatusEntry struct {
|
||||||
|
CommonName string
|
||||||
|
RealAddress string // IP:port
|
||||||
|
VPNAddress string // 客户端 VPN 内网 IP
|
||||||
|
BytesRecv int64
|
||||||
|
BytesSent int64
|
||||||
|
ConnectedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
|
||||||
|
reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),(\d+),(\d+),`)
|
||||||
|
reTime = regexp.MustCompile(`^Connected Since,([^,]+),`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
var out []StatusEntry
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "CLIENT_LIST,"):
|
||||||
|
m := reCli.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
connected, _ := time.Parse("Mon Jan 2 15:04:05 2006", m[2])
|
||||||
|
out = append(out, StatusEntry{
|
||||||
|
CommonName: m[1],
|
||||||
|
RealAddress: m[3],
|
||||||
|
VPNAddress: m[4],
|
||||||
|
BytesRecv: atoi64(m[5]),
|
||||||
|
BytesSent: atoi64(m[6]),
|
||||||
|
ConnectedAt: connected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。
|
||||||
|
func (m *Manager) IsRunning(host string, port int) bool {
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertNotAfter 解析证书的过期时间。
|
||||||
|
func (m *Manager) CertNotAfter(certPath string) (time.Time, error) {
|
||||||
|
b, err := os.ReadFile(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(b)
|
||||||
|
if block == nil {
|
||||||
|
return time.Time{}, fmt.Errorf("not a pem file")
|
||||||
|
}
|
||||||
|
c, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return c.NotAfter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- helpers -----
|
||||||
|
|
||||||
|
func runShell(s string) error {
|
||||||
|
cmd := exec.Command("bash", "-c", s)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(s, def string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi64(s string) int64 {
|
||||||
|
n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func pemTrim(b []byte) string {
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
+412
@@ -0,0 +1,412 @@
|
|||||||
|
# API 参考
|
||||||
|
|
||||||
|
所有 `/api` 路径(除 `/login`、`/health`)都需要鉴权。
|
||||||
|
|
||||||
|
## 鉴权
|
||||||
|
|
||||||
|
登录获取 JWT token(12 小时有效期):
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"username": "admin", "password": "admin123"}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
```json
|
||||||
|
{"token": "eyJhbG...", "username": "admin"}
|
||||||
|
```
|
||||||
|
|
||||||
|
后续请求携带:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
下载 `.ovpn` 也支持通过 query 携带 token:
|
||||||
|
```
|
||||||
|
GET /api/instances/<iid>/users/<uid>/ovpn?host=vpn.example.com&token=<token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 通用响应
|
||||||
|
|
||||||
|
成功返回 JSON 对象或数组;失败:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "错误描述"}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 状态码:`200`/`400`/`401`/`404`/`500`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 公共
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 当前登录用户
|
||||||
|
```
|
||||||
|
GET /api/me
|
||||||
|
→ 200 {"username": "admin"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 登出(仅前端清理 token,服务端无状态)
|
||||||
|
```
|
||||||
|
POST /api/logout
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 仪表盘
|
||||||
|
|
||||||
|
### 汇总
|
||||||
|
```
|
||||||
|
GET /api/dashboard
|
||||||
|
→ 200 {
|
||||||
|
"instances": 2,
|
||||||
|
"running": 1,
|
||||||
|
"users": 10,
|
||||||
|
"active_users": 9,
|
||||||
|
"online": 3,
|
||||||
|
"expiring_certs": 1,
|
||||||
|
"recent_audits": [...],
|
||||||
|
"recent_conn_logs": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 实例
|
||||||
|
|
||||||
|
### 列表
|
||||||
|
```
|
||||||
|
GET /api/instances
|
||||||
|
→ 200 [Instance, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 详情
|
||||||
|
```
|
||||||
|
GET /api/instances/:id
|
||||||
|
→ 200 Instance
|
||||||
|
→ 404 {"error": "instance <id> not found"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新建
|
||||||
|
```
|
||||||
|
POST /api/instances
|
||||||
|
{
|
||||||
|
"name": "prod",
|
||||||
|
"port": 1194,
|
||||||
|
"proto": "udp", // udp | tcp
|
||||||
|
"dev": "tun", // tun | tap
|
||||||
|
"subnet": "10.8.0.0/24",
|
||||||
|
"cipher": "AES-256-GCM", // 可选
|
||||||
|
"auth_digest": "SHA256", // 可选
|
||||||
|
"push_dns": "dhcp-option DNS 8.8.8.8\ndhcp-option DNS 1.1.1.1",
|
||||||
|
"push_routes": "192.168.1.0 255.255.255.0",
|
||||||
|
"extra": ""
|
||||||
|
}
|
||||||
|
→ 200 Instance
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新
|
||||||
|
```
|
||||||
|
PUT /api/instances/:id
|
||||||
|
{...同上,id 在 URL 中}
|
||||||
|
→ 200 Instance
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除(级联删除该实例下所有用户/证书)
|
||||||
|
```
|
||||||
|
DELETE /api/instances/:id
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动
|
||||||
|
```
|
||||||
|
POST /api/instances/:id/start
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止
|
||||||
|
```
|
||||||
|
POST /api/instances/:id/stop
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在线客户端(来自 status-version 3)
|
||||||
|
```
|
||||||
|
GET /api/instances/:id/online
|
||||||
|
→ 200 [
|
||||||
|
{
|
||||||
|
"CommonName": "alice",
|
||||||
|
"RealAddress": "203.0.113.10:54321",
|
||||||
|
"VPNAddress": "10.8.0.10",
|
||||||
|
"BytesRecv": 12345,
|
||||||
|
"BytesSent": 6789,
|
||||||
|
"ConnectedAt": "2026-08-09T12:34:56Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 用户
|
||||||
|
|
||||||
|
### 列表(可选按实例过滤)
|
||||||
|
```
|
||||||
|
GET /api/instances/:id/users
|
||||||
|
→ 200 [VPNUser, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新建(自动签发证书)
|
||||||
|
```
|
||||||
|
POST /api/instances/:id/users
|
||||||
|
{
|
||||||
|
"username": "alice",
|
||||||
|
"real_name": "Alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"static_ip": "10.8.0.10" // 可选
|
||||||
|
}
|
||||||
|
→ 200 VPNUser
|
||||||
|
```
|
||||||
|
|
||||||
|
### 吊销
|
||||||
|
```
|
||||||
|
POST /api/instances/:id/users/:uid/revoke
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除(同时删除证书与 .ovpn)
|
||||||
|
```
|
||||||
|
DELETE /api/instances/:id/users/:uid
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 下载客户端配置
|
||||||
|
```
|
||||||
|
GET /api/instances/:id/users/:uid/ovpn?host=vpn.example.com
|
||||||
|
→ 200 application/octet-stream (.ovpn 文件)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 证书
|
||||||
|
|
||||||
|
### 到期清单
|
||||||
|
```
|
||||||
|
GET /api/certs
|
||||||
|
→ 200 [
|
||||||
|
{
|
||||||
|
"instance_id": "uuid",
|
||||||
|
"username": "alice",
|
||||||
|
"not_before": "2026-08-09T00:00:00Z",
|
||||||
|
"not_after": "2036-08-06T00:00:00Z",
|
||||||
|
"days_left": 3649
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`days_left < 0` 表示已过期,`< 30` 在仪表盘会显示为"即将到期"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 连接日志
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/connlogs?instance=<id>
|
||||||
|
→ 200 [ConnectionLog, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
每条:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"instance_id": "uuid",
|
||||||
|
"common_name": "alice",
|
||||||
|
"real_ip": "203.0.113.10:54321",
|
||||||
|
"vpn_ip": "10.8.0.10",
|
||||||
|
"bytes_in": 12345,
|
||||||
|
"bytes_out": 6789,
|
||||||
|
"connected_at": "...",
|
||||||
|
"disconnected_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 备份
|
||||||
|
|
||||||
|
### 列表
|
||||||
|
```
|
||||||
|
GET /api/backups
|
||||||
|
→ 200 [Backup, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建
|
||||||
|
```
|
||||||
|
POST /api/backups
|
||||||
|
{"note": "before upgrade"}
|
||||||
|
→ 200 Backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 恢复(覆盖现有数据)
|
||||||
|
```
|
||||||
|
POST /api/backups/:id/restore
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除
|
||||||
|
```
|
||||||
|
DELETE /api/backups/:id
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 审计
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/audits
|
||||||
|
→ 200 [AuditLog, ...] // 最近 500 条,倒序
|
||||||
|
```
|
||||||
|
|
||||||
|
每条:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"time": "2026-08-09T12:34:56Z",
|
||||||
|
"user": "admin",
|
||||||
|
"action": "create_user",
|
||||||
|
"target": "alice",
|
||||||
|
"result": "ok",
|
||||||
|
"detail": "instance=<id>",
|
||||||
|
"ip": "127.0.0.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
可能的 action:`create_instance` / `update_instance` / `delete_instance` /
|
||||||
|
`start_instance` / `stop_instance` / `create_user` / `revoke_user` /
|
||||||
|
`delete_user` / `create_backup` / `restore_backup`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### Instance
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "prod",
|
||||||
|
"port": 1194,
|
||||||
|
"proto": "udp",
|
||||||
|
"dev": "tun",
|
||||||
|
"subnet": "10.8.0.0/24",
|
||||||
|
"cipher": "AES-256-GCM",
|
||||||
|
"auth_digest": "SHA256",
|
||||||
|
"push_dns": "...",
|
||||||
|
"push_routes": "...",
|
||||||
|
"extra": "",
|
||||||
|
"status": "running | stopped | error",
|
||||||
|
"pid": 12345,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### VPNUser
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"instance_id": "uuid",
|
||||||
|
"username": "alice",
|
||||||
|
"real_name": "Alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"enabled": true,
|
||||||
|
"static_ip": "10.8.0.10",
|
||||||
|
"created_at": "...",
|
||||||
|
"revoked_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"created_at": "...",
|
||||||
|
"size": 12345,
|
||||||
|
"note": "...",
|
||||||
|
"filename": "backup-20260809-123456-abcd1234.tar.gz",
|
||||||
|
"includes": ["pki", "instances", "clients"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## curl 示例
|
||||||
|
|
||||||
|
完整流程(假设服务在 `http://localhost:8089`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 登录
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8089/api/login \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"admin123"}' | jq -r .token)
|
||||||
|
|
||||||
|
# 2. 创建实例
|
||||||
|
curl -s -X POST http://localhost:8089/api/instances \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"name":"prod",
|
||||||
|
"port":1194,
|
||||||
|
"proto":"udp",
|
||||||
|
"dev":"tun",
|
||||||
|
"subnet":"10.8.0.0/24",
|
||||||
|
"cipher":"AES-256-GCM",
|
||||||
|
"auth_digest":"SHA256",
|
||||||
|
"push_dns":"dhcp-option DNS 1.1.1.1"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. 创建用户
|
||||||
|
IID=$(curl -s http://localhost:8089/api/instances \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')
|
||||||
|
|
||||||
|
curl -s -X POST http://localhost:8089/api/instances/$IID/users \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"username":"alice","email":"alice@example.com"}'
|
||||||
|
|
||||||
|
# 4. 拿 .ovpn
|
||||||
|
UID=$(curl -s http://localhost:8089/api/instances/$IID/users \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')
|
||||||
|
|
||||||
|
curl -s -o alice.ovpn \
|
||||||
|
"http://localhost:8089/api/instances/$IID/users/$UID/ovpn?host=vpn.example.com" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
|
||||||
|
| HTTP | 含义 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 400 | 请求参数错误或操作失败(响应体有 `error` 字段) |
|
||||||
|
| 401 | 未登录或 token 无效/过期 |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 限制
|
||||||
|
|
||||||
|
- 单实例:Web 管理界面并发 100+ 连接无压力
|
||||||
|
- 单实例 OpenVPN:理论上限 1024 个并发客户端(受限于 `topology subnet` 子网大小,可改用 `net30` 提高)
|
||||||
|
- 审计日志最多保留 5000 条
|
||||||
|
- 连接日志最多保留 20000 条
|
||||||
|
- 备份文件不自动清理,需定期手动删除过期备份
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
|
<title>OpenVPN 管理控制台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1877
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "openvpn-manager-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --port 4173"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"element-plus": "^2.8.4",
|
||||||
|
"pinia": "^2.2.4",
|
||||||
|
"vite": "^5.4.8",
|
||||||
|
"vue": "^3.5.10",
|
||||||
|
"vue-echarts": "^7.0.3",
|
||||||
|
"vue-router": "^4.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
<script setup></script>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const api = axios.create({ baseURL: '/api', timeout: 30000 })
|
||||||
|
|
||||||
|
api.interceptors.request.use(cfg => {
|
||||||
|
const t = localStorage.getItem('token')
|
||||||
|
if (t) cfg.headers.Authorization = `Bearer ${t}`
|
||||||
|
return cfg
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
r => r,
|
||||||
|
err => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(err.response?.data?.error || err.message || '请求失败')
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Auth = {
|
||||||
|
login: (username, password) => api.post('/login', { username, password }).then(r => r.data),
|
||||||
|
me: () => api.get('/me').then(r => r.data),
|
||||||
|
logout: () => api.post('/logout').then(r => r.data),
|
||||||
|
}
|
||||||
|
export const Dash = {
|
||||||
|
get: () => api.get('/dashboard').then(r => r.data),
|
||||||
|
}
|
||||||
|
export const Inst = {
|
||||||
|
list: () => api.get('/instances').then(r => r.data),
|
||||||
|
get: id => api.get(`/instances/${id}`).then(r => r.data),
|
||||||
|
create: data => api.post('/instances', data).then(r => r.data),
|
||||||
|
update: (id, data) => api.put(`/instances/${id}`, data).then(r => r.data),
|
||||||
|
delete: id => api.delete(`/instances/${id}`).then(r => r.data),
|
||||||
|
start: id => api.post(`/instances/${id}/start`).then(r => r.data),
|
||||||
|
stop: id => api.post(`/instances/${id}/stop`).then(r => r.data),
|
||||||
|
online: id => api.get(`/instances/${id}/online`).then(r => r.data),
|
||||||
|
listUsers: id => api.get(`/instances/${id}/users`).then(r => r.data),
|
||||||
|
createUser: (id, data) => api.post(`/instances/${id}/users`, data).then(r => r.data),
|
||||||
|
revokeUser: (id, uid) => api.post(`/instances/${id}/users/${uid}/revoke`).then(r => r.data),
|
||||||
|
deleteUser: (id, uid) => api.delete(`/instances/${id}/users/${uid}`).then(r => r.data),
|
||||||
|
ovpnUrl: (id, uid, host) => `/api/instances/${id}/users/${uid}/ovpn?host=${encodeURIComponent(host||'')}`,
|
||||||
|
}
|
||||||
|
export const Certs = {
|
||||||
|
list: () => api.get('/certs').then(r => r.data),
|
||||||
|
}
|
||||||
|
export const Logs = {
|
||||||
|
conns: instanceId => api.get('/connlogs', { params: { instance: instanceId || '' }}).then(r => r.data),
|
||||||
|
audits: () => api.get('/audits').then(r => r.data),
|
||||||
|
}
|
||||||
|
export const Backup = {
|
||||||
|
list: () => api.get('/backups').then(r => r.data),
|
||||||
|
create: note => api.post('/backups', { note }).then(r => r.data),
|
||||||
|
restore: id => api.post(`/backups/${id}/restore`).then(r => r.data),
|
||||||
|
delete: id => api.delete(`/backups/${id}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
html, body, #app { height: 100%; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f7fa; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
.layout-header { background: linear-gradient(90deg,#1d4ed8,#0ea5e9); color:#fff; display:flex; align-items:center; justify-content:space-between; padding:0 24px; }
|
||||||
|
.layout-header .brand { font-size:18px; font-weight:600; }
|
||||||
|
.layout-aside { background:#001529; }
|
||||||
|
.layout-aside .el-menu { border-right:0; }
|
||||||
|
.card-stat { background:#fff; padding:18px 22px; border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||||
|
.card-stat .label { color:#909399; font-size:13px; }
|
||||||
|
.card-stat .num { font-size:28px; font-weight:600; margin-top:6px; color:#1f2937; }
|
||||||
|
.muted { color:#909399; font-size:12px; }
|
||||||
|
.chart-card { background:#fff; padding:14px; border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.06); height:340px; }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<el-container style="height:100vh">
|
||||||
|
<el-aside width="220px" class="layout-aside">
|
||||||
|
<div style="height:60px;color:#fff;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:600;">
|
||||||
|
<el-icon style="margin-right:8px"><Promotion /></el-icon>OpenVPN
|
||||||
|
</div>
|
||||||
|
<el-menu :default-active="route.path" background-color="#001529" text-color="#fff" router>
|
||||||
|
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon><span>仪表盘</span></el-menu-item>
|
||||||
|
<el-menu-item index="/instances"><el-icon><Connection /></el-icon><span>实例管理</span></el-menu-item>
|
||||||
|
<el-menu-item index="/users"><el-icon><User /></el-icon><span>用户管理</span></el-menu-item>
|
||||||
|
<el-menu-item index="/certificates"><el-icon><Lock /></el-icon><span>证书管理</span></el-menu-item>
|
||||||
|
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>连接日志</span></el-menu-item>
|
||||||
|
<el-menu-item index="/backups"><el-icon><FolderOpened /></el-icon><span>备份与恢复</span></el-menu-item>
|
||||||
|
<el-menu-item index="/audits"><el-icon><Tickets /></el-icon><span>审计日志</span></el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
<el-container>
|
||||||
|
<el-header class="layout-header">
|
||||||
|
<div class="brand">
|
||||||
|
<el-icon style="margin-right:8px"><Promotion /></el-icon>OpenVPN Web 管理控制台
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-dropdown @command="cmd">
|
||||||
|
<span style="color:#fff;cursor:pointer">
|
||||||
|
<el-icon><UserFilled /></el-icon> {{ user }} <el-icon><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main style="background:#f5f7fa">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Auth } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const user = ref(localStorage.getItem('username') || 'admin')
|
||||||
|
|
||||||
|
async function cmd(c) {
|
||||||
|
if (c === 'logout') {
|
||||||
|
await Auth.logout()
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
ElMessage.success('已退出登录')
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
for (const [k,v] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(k, v)
|
||||||
|
}
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import Login from '@/views/Login.vue'
|
||||||
|
import Layout from '@/layout/Index.vue'
|
||||||
|
import Dashboard from '@/views/Dashboard.vue'
|
||||||
|
import Instances from '@/views/Instances.vue'
|
||||||
|
import Users from '@/views/Users.vue'
|
||||||
|
import Certificates from '@/views/Certificates.vue'
|
||||||
|
import Logs from '@/views/Logs.vue'
|
||||||
|
import Backups from '@/views/Backups.vue'
|
||||||
|
import Audits from '@/views/Audits.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', component: Login },
|
||||||
|
{
|
||||||
|
path: '/', component: Layout,
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/dashboard' },
|
||||||
|
{ path: 'dashboard', component: Dashboard, meta: { title: '仪表盘' } },
|
||||||
|
{ path: 'instances', component: Instances, meta: { title: '实例管理' } },
|
||||||
|
{ path: 'users', component: Users, meta: { title: '用户管理' } },
|
||||||
|
{ path: 'certificates', component: Certificates, meta: { title: '证书管理' } },
|
||||||
|
{ path: 'logs', component: Logs, meta: { title: '连接日志' } },
|
||||||
|
{ path: 'backups', component: Backups, meta: { title: '备份与恢复' } },
|
||||||
|
{ path: 'audits', component: Audits, meta: { title: '审计日志' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const t = localStorage.getItem('token')
|
||||||
|
if (!t && to.path !== '/login') return next('/login')
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">审计日志</h2>
|
||||||
|
<el-table :data="list" stripe max-height="700">
|
||||||
|
<el-table-column prop="time" label="时间" width="170">
|
||||||
|
<template #default="{row}">{{ row.time?.slice(0,19) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="user" label="操作人" width="100" />
|
||||||
|
<el-table-column prop="action" label="动作" width="180" />
|
||||||
|
<el-table-column prop="target" label="对象" width="240" />
|
||||||
|
<el-table-column prop="result" label="结果" width="80">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.result==='ok'?'success':'danger'" size="small">{{ row.result }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="detail" label="详情" />
|
||||||
|
<el-table-column prop="ip" label="IP" width="130" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Logs } from '@/api'
|
||||||
|
const list = ref([])
|
||||||
|
async function load() { list.value = await Logs.audits() }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">备份与恢复</h2>
|
||||||
|
<el-row :gutter="12" style="margin-bottom:12px">
|
||||||
|
<el-col :span="12"><el-input v-model="note" placeholder="备份备注" /></el-col>
|
||||||
|
<el-col :span="6"><el-button type="primary" @click="create"><el-icon><Plus /></el-icon>立即备份</el-button></el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-table :data="list" stripe>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="170">
|
||||||
|
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="filename" label="文件名" />
|
||||||
|
<el-table-column prop="size" label="大小" width="100">
|
||||||
|
<template #default="{row}">{{ fmt(row.size) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" />
|
||||||
|
<el-table-column label="操作" width="240">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-popconfirm title="恢复将覆盖现有数据,确定继续?" @confirm="restore(row)">
|
||||||
|
<template #reference><el-button size="small" type="warning">恢复</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
<el-popconfirm title="删除后不可恢复?" @confirm="del(row)">
|
||||||
|
<template #reference><el-button size="small" type="danger">删除</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Backup } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const note = ref('')
|
||||||
|
async function load() { list.value = await Backup.list() }
|
||||||
|
async function create() {
|
||||||
|
await Backup.create(note.value)
|
||||||
|
ElMessage.success('备份创建成功')
|
||||||
|
note.value = ''
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
async function restore(row) {
|
||||||
|
await Backup.restore(row.id)
|
||||||
|
ElMessage.success('已恢复,部分更改需要重启实例')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
async function del(row) { await Backup.delete(row.id); ElMessage.success('已删除'); load() }
|
||||||
|
function fmt(n) {
|
||||||
|
if (!n) return '0'
|
||||||
|
const u = ['B','KB','MB','GB']; let i=0,v=n
|
||||||
|
while (v>=1024 && i<u.length-1) { v/=1024; i++ }
|
||||||
|
return v.toFixed(1)+' '+u[i]
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">证书管理</h2>
|
||||||
|
<el-table :data="certs" stripe>
|
||||||
|
<el-table-column prop="username" label="用户" />
|
||||||
|
<el-table-column prop="instance_id" label="实例 ID" width="220" />
|
||||||
|
<el-table-column prop="not_after" label="到期时间" width="200">
|
||||||
|
<template #default="{row}">{{ row.not_after?.slice(0,10) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="days_left" label="剩余天数" width="120">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.days_left<0?'danger':(row.days_left<30?'warning':'success')">
|
||||||
|
{{ row.days_left }} 天
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Certs } from '@/api'
|
||||||
|
|
||||||
|
const certs = ref([])
|
||||||
|
async function load() { certs.value = await Certs.list() }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">仪表盘</h2>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="6"><div class="card-stat"><div class="label">实例数</div><div class="num">{{ data.instances || 0 }}</div><div class="muted">运行中 {{ data.running || 0 }}</div></div></el-col>
|
||||||
|
<el-col :span="6"><div class="card-stat"><div class="label">用户数</div><div class="num">{{ data.users || 0 }}</div><div class="muted">启用 {{ data.active_users || 0 }}</div></div></el-col>
|
||||||
|
<el-col :span="6"><div class="card-stat"><div class="label">当前在线</div><div class="num">{{ data.online || 0 }}</div><div class="muted">来自 status.log</div></div></el-col>
|
||||||
|
<el-col :span="6"><div class="card-stat"><div class="label">即将到期证书(30天)</div><div class="num" :style="{color:(data.expiring_certs>0)?'#f56c6c':'#1f2937'}">{{ data.expiring_certs || 0 }}</div><div class="muted">需及时续签</div></div></el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16" style="margin-top:16px">
|
||||||
|
<el-col :span="12">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h4 style="margin:0 0 8px">最近审计</h4>
|
||||||
|
<el-table :data="data.recent_audits || []" size="small" max-height="280">
|
||||||
|
<el-table-column prop="time" label="时间" width="170">
|
||||||
|
<template #default="{row}">{{ row.time?.slice(0,19) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="user" label="用户" width="90" />
|
||||||
|
<el-table-column prop="action" label="操作" width="160" />
|
||||||
|
<el-table-column prop="result" label="结果" width="70">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.result==='ok'?'success':'danger'" size="small">{{ row.result }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip" label="IP" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h4 style="margin:0 0 8px">最近连接</h4>
|
||||||
|
<el-table :data="data.recent_conn_logs || []" size="small" max-height="280">
|
||||||
|
<el-table-column prop="common_name" label="用户" />
|
||||||
|
<el-table-column prop="real_ip" label="来源 IP" width="160" />
|
||||||
|
<el-table-column prop="vpn_ip" label="VPN IP" width="140" />
|
||||||
|
<el-table-column prop="bytes_in" label="下行" width="100">
|
||||||
|
<template #default="{row}">{{ fmtBytes(row.bytes_in) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="bytes_out" label="上行" width="100">
|
||||||
|
<template #default="{row}">{{ fmtBytes(row.bytes_out) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { Dash } from '@/api'
|
||||||
|
|
||||||
|
const data = ref({})
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
data.value = await Dash.get()
|
||||||
|
}
|
||||||
|
function fmtBytes(n) {
|
||||||
|
if (!n) return '0'
|
||||||
|
const u = ['B','KB','MB','GB','TB']
|
||||||
|
let i = 0, v = n
|
||||||
|
while (v >= 1024 && i < u.length-1) { v /= 1024; i++ }
|
||||||
|
return v.toFixed(1)+' '+u[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { refresh(); timer = setInterval(refresh, 10000) })
|
||||||
|
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">实例管理</h2>
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新建实例</el-button>
|
||||||
|
<el-button @click="load"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" stripe>
|
||||||
|
<el-table-column prop="name" label="名称" />
|
||||||
|
<el-table-column prop="port" label="端口" width="80" />
|
||||||
|
<el-table-column prop="proto" label="协议" width="80" />
|
||||||
|
<el-table-column prop="dev" label="设备" width="80" />
|
||||||
|
<el-table-column prop="subnet" label="子网" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.status==='running'?'success':'info'" size="small">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="320">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button size="small" type="primary" :disabled="row.status==='running'" @click="start(row)">启动</el-button>
|
||||||
|
<el-button size="small" type="warning" :disabled="row.status!=='running'" @click="stop(row)">停止</el-button>
|
||||||
|
<el-button size="small" @click="goUsers(row)">用户</el-button>
|
||||||
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-popconfirm title="确定删除实例及其所有用户/证书?" @confirm="del(row)">
|
||||||
|
<template #reference><el-button size="small" type="danger">删除</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="dlg" :title="form.id?'编辑实例':'新建实例'" width="560px">
|
||||||
|
<el-form :model="form" label-width="100px">
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="form.name" :disabled="!!form.id" placeholder="字母数字下划线,作为目录名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="端口"><el-input-number v-model="form.port" :min="1" :max="65535" /></el-form-item>
|
||||||
|
<el-form-item label="协议">
|
||||||
|
<el-select v-model="form.proto"><el-option label="udp" value="udp"/><el-option label="tcp" value="tcp"/></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备">
|
||||||
|
<el-select v-model="form.dev"><el-option label="tun" value="tun"/><el-option label="tap" value="tap"/></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="子网"><el-input v-model="form.subnet" placeholder="10.8.0.0/24"/></el-form-item>
|
||||||
|
<el-form-item label="加密">
|
||||||
|
<el-select v-model="form.cipher"><el-option label="AES-256-GCM" value="AES-256-GCM"/><el-option label="AES-128-GCM" value="AES-128-GCM"/><el-option label="CHACHA20-POLY1305" value="CHACHA20-POLY1305"/></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="摘要">
|
||||||
|
<el-select v-model="form.auth_digest"><el-option label="SHA256" value="SHA256"/><el-option label="SHA512" value="SHA512"/></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="推送 DNS"><el-input v-model="form.push_dns" type="textarea" :rows="3" placeholder="每行一条,例如 dhcp-option DNS 8.8.8.8"/></el-form-item>
|
||||||
|
<el-form-item label="推送路由"><el-input v-model="form.push_routes" type="textarea" :rows="3" placeholder="每行一条 CIDR"/></el-form-item>
|
||||||
|
<el-form-item label="自定义"><el-input v-model="form.extra" type="textarea" :rows="3" placeholder="追加到 server.conf 末尾"/></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dlg=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Inst } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const dlg = ref(false)
|
||||||
|
const form = reactive({})
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function load() { list.value = await Inst.list() }
|
||||||
|
function openCreate() {
|
||||||
|
Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', id:'' })
|
||||||
|
dlg.value = true
|
||||||
|
}
|
||||||
|
function openEdit(row) {
|
||||||
|
Object.assign(form, row)
|
||||||
|
dlg.value = true
|
||||||
|
}
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
await Inst.update(form.id, form)
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
} else {
|
||||||
|
await Inst.create(form)
|
||||||
|
ElMessage.success('已创建')
|
||||||
|
}
|
||||||
|
dlg.value = false
|
||||||
|
load()
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
async function start(row) { await Inst.start(row.id); ElMessage.success('已发送启动指令'); load() }
|
||||||
|
async function stop(row) { await Inst.stop(row.id); ElMessage.success('已停止'); load() }
|
||||||
|
async function del(row) { await Inst.delete(row.id); ElMessage.success('已删除'); load() }
|
||||||
|
function goUsers(row) { router.push({ path:'/users', query: { instance: row.id } }) }
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<h2>OpenVPN 管理控制台</h2>
|
||||||
|
<p class="muted">请使用管理员账号登录</p>
|
||||||
|
<el-form :model="form" label-width="80px" @submit.prevent="submit">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="form.username" placeholder="admin" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="form.password" type="password" show-password @keyup.enter="submit" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
|
||||||
|
</el-form>
|
||||||
|
<p class="muted">首次部署默认账号 admin / admin123,请在系统中修改</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Auth } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const form = reactive({ username: 'admin', password: 'admin123' })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r = await Auth.login(form.username, form.password)
|
||||||
|
localStorage.setItem('token', r.token)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.replace('/dashboard')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.login-page { min-height:100vh; background:linear-gradient(135deg,#1d4ed8,#0ea5e9); display:flex; align-items:center; justify-content:center; }
|
||||||
|
.login-card { width:380px; background:#fff; padding:32px; border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); }
|
||||||
|
h2 { margin:0 0 4px; }
|
||||||
|
.muted { color:#909399; font-size:13px; margin:8px 0 22px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">连接日志</h2>
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<el-select v-model="instanceId" placeholder="选择实例" clearable @change="load" style="width:240px">
|
||||||
|
<el-option v-for="i in instances" :key="i.id" :value="i.id" :label="i.name" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" stripe>
|
||||||
|
<el-table-column prop="common_name" label="用户" />
|
||||||
|
<el-table-column prop="real_ip" label="来源 IP" width="160" />
|
||||||
|
<el-table-column prop="vpn_ip" label="VPN IP" width="140" />
|
||||||
|
<el-table-column prop="bytes_in" label="下行" width="100">
|
||||||
|
<template #default="{row}">{{ fmt(row.bytes_in) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="bytes_out" label="上行" width="100">
|
||||||
|
<template #default="{row}">{{ fmt(row.bytes_out) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="connected_at" label="开始时间" width="170">
|
||||||
|
<template #default="{row}">{{ row.connected_at?.slice(0,19) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="disconnected_at" label="结束时间" width="170">
|
||||||
|
<template #default="{row}">{{ row.disconnected_at?.slice(0,19) || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Logs, Inst } from '@/api'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const instances = ref([])
|
||||||
|
const instanceId = ref('')
|
||||||
|
|
||||||
|
async function load() { list.value = await Logs.conns(instanceId.value) }
|
||||||
|
function fmt(n) {
|
||||||
|
if (!n) return '0'
|
||||||
|
const u = ['B','KB','MB','GB','TB']; let i=0,v=n
|
||||||
|
while (v>=1024 && i<u.length-1) { v/=1024; i++ }
|
||||||
|
return v.toFixed(1)+' '+u[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => { instances.value = await Inst.list(); load() })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">用户管理</h2>
|
||||||
|
<el-row :gutter="12" style="margin-bottom:12px">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-select v-model="instanceId" placeholder="选择实例" filterable @change="loadUsers" style="width:100%">
|
||||||
|
<el-option v-for="i in instances" :key="i.id" :value="i.id" :label="`${i.name} (${i.proto}/${i.port})`" />
|
||||||
|
</el-select>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6"><el-input v-model="host" placeholder="客户端连接的远端域名/IP" /></el-col>
|
||||||
|
<el-col :span="4"><el-button @click="loadUsers">刷新</el-button></el-col>
|
||||||
|
<el-col :span="6" style="text-align:right">
|
||||||
|
<el-button type="primary" :disabled="!instanceId" @click="dlg=true"><el-icon><Plus /></el-icon>新建用户</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-table :data="users" stripe>
|
||||||
|
<el-table-column prop="username" label="用户名(CN)" />
|
||||||
|
<el-table-column prop="real_name" label="备注" />
|
||||||
|
<el-table-column prop="email" label="邮箱" />
|
||||||
|
<el-table-column prop="static_ip" label="固定 IP" width="140" />
|
||||||
|
<el-table-column prop="enabled" label="状态" width="100">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.enabled?'success':'danger'" size="small">{{ row.enabled?'启用':'已吊销' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="170">
|
||||||
|
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="260">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button size="small" @click="download(row)">下载 .ovpn</el-button>
|
||||||
|
<el-button v-if="row.enabled" size="small" type="warning" @click="revoke(row)">吊销</el-button>
|
||||||
|
<el-popconfirm title="确定删除此用户及其证书?" @confirm="del(row)">
|
||||||
|
<template #reference><el-button size="small" type="danger">删除</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="dlg" title="新建用户" width="500px">
|
||||||
|
<el-form :model="form" label-width="100px">
|
||||||
|
<el-form-item label="用户名(CN)"><el-input v-model="form.username" placeholder="字母数字下划线"/></el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="form.real_name" /></el-form-item>
|
||||||
|
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||||
|
<el-form-item label="固定 IP"><el-input v-model="form.static_ip" placeholder="可选, 如 10.8.0.10" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dlg=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submit">生成并签发</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { Inst } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const instances = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const instanceId = ref('')
|
||||||
|
const host = ref(window.location.hostname)
|
||||||
|
const dlg = ref(false)
|
||||||
|
const form = reactive({ username:'', real_name:'', email:'', static_ip:'' })
|
||||||
|
|
||||||
|
async function loadInstances() {
|
||||||
|
instances.value = await Inst.list()
|
||||||
|
if (!instanceId.value && instances.value.length) {
|
||||||
|
instanceId.value = route.query.instance || instances.value[0].id
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadUsers() {
|
||||||
|
if (!instanceId.value) return
|
||||||
|
users.value = await Inst.listUsers(instanceId.value)
|
||||||
|
}
|
||||||
|
watch(() => route.query.instance, v => { if (v) { instanceId.value = v; loadUsers() } })
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!form.username) return ElMessage.warning('请输入用户名')
|
||||||
|
await Inst.createUser(instanceId.value, { ...form })
|
||||||
|
ElMessage.success('已创建并签发证书')
|
||||||
|
dlg.value = false
|
||||||
|
Object.assign(form, { username:'', real_name:'', email:'', static_ip:'' })
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
async function revoke(row) {
|
||||||
|
await Inst.revokeUser(instanceId.value, row.id)
|
||||||
|
ElMessage.success('已吊销')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
async function del(row) {
|
||||||
|
await Inst.deleteUser(instanceId.value, row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
async function download(row) {
|
||||||
|
const tok = localStorage.getItem('token')
|
||||||
|
const url = Inst.ovpnUrl(instanceId.value, row.id, host.value)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url + (url.includes('?')?'&':'?') + `token=${encodeURIComponent(tok)}`
|
||||||
|
a.download = `${row.username}.ovpn`
|
||||||
|
document.body.appendChild(a); a.click(); a.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadInstances)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, 'src') }
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://127.0.0.1:8089', changeOrigin: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false,
|
||||||
|
chunkSizeWarningLimit: 2000
|
||||||
|
}
|
||||||
|
})
|
||||||
Executable
+198
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# openvpn-manager 一键部署脚本
|
||||||
|
# 适用: Ubuntu 20.04+/Debian 11+/CentOS Stream 9+
|
||||||
|
# 行为: 装依赖 → 编译前端 → 编译 Go 二进制 → 装到 /opt/openvpn-manager → 写 systemd
|
||||||
|
# 用法: sudo ./install.sh [卸载参数 -u] [--port 8089] [--user admin] [--pass xxxx]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="openvpn-manager"
|
||||||
|
INSTALL_DIR="/opt/openvpn-manager"
|
||||||
|
SERVICE_NAME="openvpn-manager"
|
||||||
|
|
||||||
|
# 默认配置(可被命令行覆盖)
|
||||||
|
PORT=8089
|
||||||
|
ADMIN_USER="admin"
|
||||||
|
ADMIN_PASS="admin123"
|
||||||
|
JWT_SECRET="$(openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | xxd -p -c 64)"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
用法: $0 [选项]
|
||||||
|
-u, --uninstall 卸载(停止服务, 删除 /opt/openvpn-manager 与 systemd 单元)
|
||||||
|
--port PORT Web 监听端口 (默认 8089)
|
||||||
|
--user USER 管理员用户名 (默认 admin)
|
||||||
|
--pass PASS 管理员密码 (默认 admin123)
|
||||||
|
--dir DIR 安装目录 (默认 /opt/openvpn-manager)
|
||||||
|
-h, --help 显示本帮助
|
||||||
|
|
||||||
|
示例:
|
||||||
|
sudo $0
|
||||||
|
sudo $0 --port 9090 --user root --pass 'StrongPass!2026'
|
||||||
|
sudo $0 -u
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall() {
|
||||||
|
echo "==> 停止并禁用服务"
|
||||||
|
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||||
|
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||||
|
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "==> 删除安装目录 $INSTALL_DIR"
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
echo "==> 完成(已保留源码目录, 如需彻底清理请手动 rm -rf 源码目录)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 参数解析 ----------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-u|--uninstall) uninstall; exit 0 ;;
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
--user) ADMIN_USER="$2"; shift 2 ;;
|
||||||
|
--pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||||
|
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "未知参数: $1"; usage; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- 权限检查 ----------
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "请使用 root 运行: sudo $0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 发行版识别 ----------
|
||||||
|
. /etc/os-release 2>/dev/null || true
|
||||||
|
echo "==> 操作系统: ${PRETTY_NAME:-unknown}"
|
||||||
|
|
||||||
|
SCRIPTPATH="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
|
SRC_DIR="$( cd -- "$SCRIPTPATH/.." &> /dev/null && pwd )"
|
||||||
|
echo "==> 源码目录: $SRC_DIR"
|
||||||
|
echo "==> 安装目录: $INSTALL_DIR"
|
||||||
|
|
||||||
|
# ---------- 包管理器 ----------
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
PKG="apt-get"
|
||||||
|
PKG_INSTALL="apt-get install -y --no-install-recommends"
|
||||||
|
PKG_UPDATE="apt-get update -qq"
|
||||||
|
DEPS=(curl ca-certificates openssl openvpn easy-rsa nodejs npm golang-go git)
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
PKG="dnf"
|
||||||
|
PKG_INSTALL="dnf install -y"
|
||||||
|
PKG_UPDATE="dnf makecache -q"
|
||||||
|
DEPS=(curl ca-certificates openssl openvpn easy-rsa nodejs npm golang git)
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
PKG="yum"
|
||||||
|
PKG_INSTALL="yum install -y"
|
||||||
|
PKG_UPDATE="yum makecache fast -q"
|
||||||
|
DEPS=(curl ca-certificates openssl openvpn easy-rsa nodejs npm golang git)
|
||||||
|
else
|
||||||
|
echo "不支持的发行版(需 apt/dnf/yum 之一)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 使用包管理器: $PKG"
|
||||||
|
|
||||||
|
echo "==> 更新包索引"
|
||||||
|
$PKG_UPDATE || true
|
||||||
|
|
||||||
|
echo "==> 安装系统依赖"
|
||||||
|
$PKG_INSTALL "${DEPS[@]}" || {
|
||||||
|
echo "依赖安装失败, 请检查网络/源"; exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 校验版本 ----------
|
||||||
|
echo "==> 检查工具版本"
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
go version
|
||||||
|
openssl version
|
||||||
|
openvpn --version | head -1
|
||||||
|
|
||||||
|
# 若 Go < 1.21, 警告但不中断(本项目最低 1.21)
|
||||||
|
GO_VER=$(go version | awk '{print $3}' | sed 's/go//')
|
||||||
|
echo "==> Go 版本: $GO_VER"
|
||||||
|
|
||||||
|
# ---------- 构建前端 ----------
|
||||||
|
echo "==> 构建前端 (npm install + build)"
|
||||||
|
cd "$SRC_DIR/frontend"
|
||||||
|
npm install --include=dev --no-audit --no-fund
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# ---------- 构建后端 ----------
|
||||||
|
echo "==> 编译 Go 后端二进制"
|
||||||
|
cd "$SRC_DIR/backend"
|
||||||
|
go build -ldflags "-s -w" -o "$SRC_DIR/bin/openvpn-manager" ./cmd/server
|
||||||
|
|
||||||
|
# ---------- 安装 ----------
|
||||||
|
echo "==> 安装到 $INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"/{bin,data,dist}
|
||||||
|
cp -r "$SRC_DIR/bin/openvpn-manager" "$INSTALL_DIR/bin/"
|
||||||
|
cp -r "$SRC_DIR/dist/"* "$INSTALL_DIR/dist/"
|
||||||
|
|
||||||
|
# 写入环境变量文件(便于 systemd 引用)
|
||||||
|
cat > "$INSTALL_DIR/.env" <<EOF
|
||||||
|
OVPNMGR_HOST=0.0.0.0
|
||||||
|
OVPNMGR_PORT=$PORT
|
||||||
|
OVPNMGR_ADMIN_USER=$ADMIN_USER
|
||||||
|
OVPNMGR_ADMIN_PASS=$ADMIN_PASS
|
||||||
|
OVPNMGR_JWT_SECRET=$JWT_SECRET
|
||||||
|
OVPNMGR_OPENVPN_BIN=$(command -v openvpn)
|
||||||
|
OVPNMGR_EASYRSA_BIN=$(command -v easyrsa || echo easyrsa)
|
||||||
|
EOF
|
||||||
|
chmod 600 "$INSTALL_DIR/.env"
|
||||||
|
|
||||||
|
# 渲染 systemd 单元(替换环境变量)
|
||||||
|
sed -e "s|Environment=OVPNMGR_PORT=.*|Environment=OVPNMGR_PORT=$PORT|" \
|
||||||
|
-e "s|Environment=OVPNMGR_ADMIN_USER=.*|Environment=OVPNMGR_ADMIN_USER=$ADMIN_USER|" \
|
||||||
|
-e "s|Environment=OVPNMGR_ADMIN_PASS=.*|Environment=OVPNMGR_ADMIN_PASS=$ADMIN_PASS|" \
|
||||||
|
-e "s|Environment=OVPNMGR_JWT_SECRET=.*|Environment=OVPNMGR_JWT_SECRET=$JWT_SECRET|" \
|
||||||
|
-e "s|/opt/openvpn-manager|$INSTALL_DIR|g" \
|
||||||
|
"$SRC_DIR/systemd/${SERVICE_NAME}.service" > "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
# ---------- 健康检查 ----------
|
||||||
|
sleep 2
|
||||||
|
HEALTH_URL="http://127.0.0.1:${PORT}/api/health"
|
||||||
|
echo "==> 健康检查 $HEALTH_URL"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
echo "✓ 服务已就绪"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ $i -eq 5 ]]; then
|
||||||
|
echo "✗ 健康检查失败, 查看: journalctl -u $SERVICE_NAME -n 50"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- 完成提示 ----------
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
OpenVPN Manager 安装完成
|
||||||
|
================================================================
|
||||||
|
访问地址 : http://<服务器IP>:${PORT}
|
||||||
|
用户名 : ${ADMIN_USER}
|
||||||
|
密码 : ${ADMIN_PASS}
|
||||||
|
安装目录 : ${INSTALL_DIR}
|
||||||
|
数据目录 : ${INSTALL_DIR}/data
|
||||||
|
配置单元 : /etc/systemd/system/${SERVICE_NAME}.service
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
systemctl status ${SERVICE_NAME} # 查看状态
|
||||||
|
systemctl restart ${SERVICE_NAME} # 重启
|
||||||
|
systemctl stop ${SERVICE_NAME} # 停止
|
||||||
|
journalctl -u ${SERVICE_NAME} -f # 跟踪日志
|
||||||
|
${INSTALL_DIR}/bin/openvpn-manager --help
|
||||||
|
|
||||||
|
卸载:
|
||||||
|
sudo $0 -u
|
||||||
|
================================================================
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenVPN Manager Web Service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Environment=OVPNMGR_HOST=0.0.0.0
|
||||||
|
Environment=OVPNMGR_PORT=8089
|
||||||
|
Environment=OVPNMGR_ADMIN_USER=admin
|
||||||
|
# 修改为你自己的初始密码(首次登录后请立刻在系统中修改)
|
||||||
|
Environment=OVPNMGR_ADMIN_PASS=admin123
|
||||||
|
# JWT 签名密钥,生产环境务必改成随机字符串
|
||||||
|
Environment=OVPNMGR_JWT_SECRET=change-me-in-prod-please
|
||||||
|
WorkingDirectory=/opt/openvpn-manager
|
||||||
|
ExecStart=/opt/openvpn-manager/bin/openvpn-manager --data /opt/openvpn-manager/data --dist /opt/openvpn-manager/dist
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user