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:
cnbugs
2026-08-09 20:32:37 +08:00
commit 77f8b59290
34 changed files with 6063 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
data/
bin/
dist/
frontend/node_modules/
*.log
*.tmp
.DS_Store
.vscode/
.idea/
*.swp
+21
View File
@@ -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.
+679
View File
@@ -0,0 +1,679 @@
# OpenVPN Manager
> 一个开箱即用的 OpenVPN Web 管理控制台 —— 多实例托管、客户端证书一键签发、流量审计、证书到期提醒、自动备份与恢复。
> 基于 **Go (Gin) + Vue 3 + Element Plus + ECharts**,单二进制部署。
![dashboard](https://img.shields.io/badge/Go-1.21+-00ADD8?logo=go) ![Vue](https://img.shields.io/badge/Vue-3-4FC08D?logo=vue.js) ![License](https://img.shields.io/badge/license-MIT-green)
---
## 目录
- [功能特性](#功能特性)
- [架构概览](#架构概览)
- [一、服务端部署](#一服务端部署)
- [系统要求](#系统要求)
- [一键安装](#一键安装)
- [安装参数](#安装参数)
- [手动安装](#手动安装)
- [目录结构](#目录结构)
- [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)
+88
View File
@@ -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:])
}
+47
View File
@@ -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
View File
@@ -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=
+402
View File
@@ -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:])
}
+78
View File
@@ -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
}
+57
View File
@@ -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()
}
}
+84
View File
@@ -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"`
}
+489
View File
@@ -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 下载/重新生成 .ovpnremoteHost 由前端传入。
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)
}
+320
View File
@@ -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()
}
+381
View File
@@ -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
View File
@@ -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 条
- 备份文件不自动清理,需定期手动删除过期备份
+12
View File
@@ -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>
+1877
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
<template>
<router-view />
</template>
<script setup></script>
+65
View File
@@ -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
+12
View File
@@ -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; }
+59
View File
@@ -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>
+18
View File
@@ -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')
+38
View File
@@ -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
+27
View File
@@ -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>
+57
View File
@@ -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>
+27
View File
@@ -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>
+69
View File
@@ -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>
+102
View File
@@ -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>
+46
View File
@@ -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>
+45
View File
@@ -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>
+110
View File
@@ -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>
+22
View File
@@ -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
}
})
+198
View File
@@ -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
+23
View File
@@ -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