From 77f8b59290152b58c3f54b3a7280d67a56a4b24d Mon Sep 17 00:00:00 2001 From: cnbugs Date: Sun, 9 Aug 2026 20:32:37 +0800 Subject: [PATCH] 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 --- .gitignore | 10 + LICENSE | 21 + README.md | 679 ++++++++++ backend/cmd/server/main.go | 88 ++ backend/go.mod | 47 + backend/go.sum | 161 +++ backend/internal/api/router.go | 402 ++++++ backend/internal/config/config.go | 78 ++ backend/internal/middleware/auth.go | 57 + backend/internal/model/model.go | 84 ++ backend/internal/service/service.go | 489 +++++++ backend/internal/store/store.go | 320 +++++ backend/pkg/openvpn/manager.go | 381 ++++++ docs/API.md | 412 ++++++ frontend/index.html | 12 + frontend/package-lock.json | 1877 +++++++++++++++++++++++++++ frontend/package.json | 23 + frontend/src/App.vue | 4 + frontend/src/api/index.js | 65 + frontend/src/assets/main.css | 12 + frontend/src/layout/Index.vue | 59 + frontend/src/main.js | 18 + frontend/src/router/index.js | 38 + frontend/src/views/Audits.vue | 27 + frontend/src/views/Backups.vue | 57 + frontend/src/views/Certificates.vue | 27 + frontend/src/views/Dashboard.vue | 69 + frontend/src/views/Instances.vue | 102 ++ frontend/src/views/Login.vue | 46 + frontend/src/views/Logs.vue | 45 + frontend/src/views/Users.vue | 110 ++ frontend/vite.config.js | 22 + scripts/install.sh | 198 +++ systemd/openvpn-manager.service | 23 + 34 files changed, 6063 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/api/router.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/middleware/auth.go create mode 100644 backend/internal/model/model.go create mode 100644 backend/internal/service/service.go create mode 100644 backend/internal/store/store.go create mode 100644 backend/pkg/openvpn/manager.go create mode 100644 docs/API.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/assets/main.css create mode 100644 frontend/src/layout/Index.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/views/Audits.vue create mode 100644 frontend/src/views/Backups.vue create mode 100644 frontend/src/views/Certificates.vue create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Instances.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/Logs.vue create mode 100644 frontend/src/views/Users.vue create mode 100644 frontend/vite.config.js create mode 100755 scripts/install.sh create mode 100644 systemd/openvpn-manager.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..200aafe --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +data/ +bin/ +dist/ +frontend/node_modules/ +*.log +*.tmp +.DS_Store +.vscode/ +.idea/ +*.swp \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c505821 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9b0ad5 --- /dev/null +++ b/README.md @@ -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 │ + │ │ │ --cd │ + ▼ │ │ → status.log │ + ┌──────────────────────────┐ │ └────────────────────────┘ + │ │ │ + │ pki/ │ │ + │ instances//... │ │ + │ clients/// │ │ + │ 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 && 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/ +│ │ └── / +│ │ ├── server.conf # OpenVPN 服务端配置 +│ │ ├── status.log # OpenVPN 状态文件(10s 周期) +│ │ ├── ipp.txt # IP 池持久化 +│ │ ├── ccd/ # 客户端配置目录(固定 IP) +│ │ ├── logs/openvpn.log +│ │ └── pki/ +│ │ ├── issued/.crt +│ │ └── private/.key +│ ├── clients/ +│ │ └── /.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`
`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 证书(`...`) +- 客户端证书(`...`) +- 客户端私钥(`...`) +- 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 + + +-----BEGIN CERTIFICATE----- +... CA 证书内容 ... +-----END CERTIFICATE----- + + + +-----BEGIN CERTIFICATE----- +... 客户端证书 ... +-----END CERTIFICATE----- + + + +-----BEGIN PRIVATE KEY----- +... 客户端私钥(请勿泄露)... +-----END PRIVATE KEY----- + + + +-----BEGIN OpenVPN Static key V1----- +... TLS-Auth 共享密钥 ... +-----END OpenVPN Static key V1----- + +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 `。 + +完整列表见 [`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) \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..3e6aa61 --- /dev/null +++ b/backend/cmd/server/main.go @@ -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:]) +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..efa1bc3 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..1fbdee0 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go new file mode 100644 index 0000000..6480db6 --- /dev/null +++ b/backend/internal/api/router.go @@ -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:]) +} \ No newline at end of file diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..1c88678 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,78 @@ +package config + +import ( + "os" + "path/filepath" + "strconv" +) + +// Config 包含 Web 管理界面的运行时配置。 +// 数据目录结构: +// / +// config.yaml - 全局配置 +// db.json - 用户/实例元数据(简化存储) +// pki/ - CA 与证书(PKI 基础设施) +// ca.crt / ca.key / dh.pem / ta.key +// instances// - 每个 OpenVPN 实例的目录 +// server.conf +// pki/{issued,private,csd} +// ccd/ - 客户端配置目录 +// status.log - openvpn --status 周期输出 +// logs/ - openvpn 运行日志 +// clients/// - 生成的 .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 +} \ No newline at end of file diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..c6c68cb --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -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() + } +} \ No newline at end of file diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go new file mode 100644 index 0000000..346be09 --- /dev/null +++ b/backend/internal/model/model.go @@ -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"` +} \ No newline at end of file diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go new file mode 100644 index 0000000..8e253a6 --- /dev/null +++ b/backend/internal/service/service.go @@ -0,0 +1,489 @@ +package service + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "openvpn-manager/internal/config" + "openvpn-manager/internal/model" + "openvpn-manager/internal/store" + "openvpn-manager/pkg/openvpn" +) + +// Service 业务逻辑聚合,供 API 层调用。 +// 任何对实例/用户/证书/备份的变更都应经过这里,从而写入审计日志。 +type Service struct { + Cfg *config.Config + Store *store.Store + Ovm *openvpn.Manager +} + +func New(cfg *config.Config, st *store.Store, ovm *openvpn.Manager) *Service { + return &Service{Cfg: cfg, Store: st, Ovm: ovm} +} + +func (s *Service) audit(c context.Context, action, target, detail, result, ip string) { + username, _ := c.Value("user").(string) + if username == "" { + username = "system" + } + _ = s.Store.AppendAudit(model.AuditLog{ + ID: uuid.NewString(), + Time: time.Now(), + User: username, + Action: action, + Target: target, + Result: result, + Detail: detail, + IP: ip, + }) +} + +// AuditForAPI 在 API 层被调用时手动写入(因为 gin context 转为 context.Context)。 +func (s *Service) AuditForAPI(c *gin.Context, action, target, detail, result string) { + username, _ := c.Get("user") + un, _ := username.(string) + if un == "" { + un = "system" + } + _ = s.Store.AppendAudit(model.AuditLog{ + ID: uuid.NewString(), + Time: time.Now(), + User: un, + Action: action, + Target: target, + Result: result, + Detail: detail, + IP: c.ClientIP(), + }) +} + +// CreateInstance 新建一个 OpenVPN 实例,并签发服务端证书、生成 server.conf。 +func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) { + if in.Name == "" { + return nil, fmt.Errorf("name required") + } + if in.Port == 0 { + return nil, fmt.Errorf("port required") + } + if in.Proto == "" { + in.Proto = "udp" + } + if in.Dev == "" { + in.Dev = "tun" + } + if in.Subnet == "" { + in.Subnet = "10.8.0.0/24" + } + // 名称查重 + if _, err := s.Store.GetInstanceByName(in.Name); err == nil { + return nil, fmt.Errorf("instance %s already exists", in.Name) + } + // 确保 CA + if err := s.Ovm.EnsureCA(); err != nil { + return nil, err + } + in.ID = uuid.NewString() + in.Status = "stopped" + in.CreatedAt = time.Now() + in.UpdatedAt = time.Now() + // 创建实例目录 + if err := os.MkdirAll(s.Cfg.InstanceDir(in.Name), 0o755); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs"), 0o755); err != nil { + return nil, err + } + // 签发服务端证书 + if err := s.Ovm.IssueServerCert(in.Name); err != nil { + return nil, fmt.Errorf("issue server cert: %w", err) + } + // 写 server.conf + ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd") + if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil { + return nil, fmt.Errorf("write conf: %w", err) + } + if err := s.Store.UpsertInstance(in); err != nil { + return nil, err + } + return &in, nil +} + +// UpdateInstance 仅更新可热改字段(端口/子网需要重启生效)。 +func (s *Service) UpdateInstance(in model.Instance) error { + old, err := s.Store.GetInstance(in.ID) + if err != nil { + return err + } + in.CreatedAt = old.CreatedAt + in.UpdatedAt = time.Now() + in.Status = old.Status + in.PID = old.PID + ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd") + if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil { + return err + } + return s.Store.UpsertInstance(in) +} + +// DeleteInstance 移除实例及其 PKI/配置。 +func (s *Service) DeleteInstance(id string) error { + in, err := s.Store.GetInstance(id) + if err != nil { + return err + } + if in.Status == "running" { + _ = s.StopInstance(id) + } + // 清理用户记录与目录 + for _, u := range s.Store.ListUsers(id) { + _ = s.Store.DeleteUser(u.ID) + } + _ = os.RemoveAll(s.Cfg.InstanceDir(in.Name)) + _ = os.RemoveAll(filepath.Join(s.Cfg.ClientsDir(), in.Name)) + return s.Store.DeleteInstance(id) +} + +// StartInstance 在前台启动 openvpn。 +// 注意:本服务应以 root 运行;非 root 场景下应通过 systemd 单元托管。 +func (s *Service) StartInstance(id string) error { + in, err := s.Store.GetInstance(id) + if err != nil { + return err + } + if in.Status == "running" { + return fmt.Errorf("already running") + } + conf := s.Ovm.InstanceConf(in.Name) + if _, err := os.Stat(conf); err != nil { + return fmt.Errorf("conf missing: %w", err) + } + cmd := exec.Command(s.Cfg.OpenVPNBin, + "--cd", s.Cfg.InstanceDir(in.Name), + "--config", conf) + logf, _ := os.OpenFile(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs", "openvpn-stdout.log"), + os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if logf != nil { + cmd.Stdout = logf + cmd.Stderr = logf + } + if err := cmd.Start(); err != nil { + return err + } + in.Status = "running" + in.PID = cmd.Process.Pid + in.UpdatedAt = time.Now() + _ = s.Store.UpsertInstance(*in) + // 后台释放 + go func() { + _ = cmd.Wait() + // 进程退出时回写状态(简单模型) + cur, err := s.Store.GetInstance(id) + if err == nil && cur.PID == cmd.Process.Pid { + cur.Status = "stopped" + cur.PID = 0 + cur.UpdatedAt = time.Now() + _ = s.Store.UpsertInstance(*cur) + } + }() + return nil +} + +// StopInstance 通过 SIGTERM 停止实例。 +func (s *Service) StopInstance(id string) error { + in, err := s.Store.GetInstance(id) + if err != nil { + return err + } + if in.PID == 0 { + in.Status = "stopped" + return s.Store.UpsertInstance(*in) + } + proc, err := os.FindProcess(in.PID) + if err != nil { + return err + } + if err := proc.Signal(os.Interrupt); err != nil { + // 兜底:直接 Kill + _ = proc.Signal(os.Kill) + } + in.Status = "stopped" + in.PID = 0 + in.UpdatedAt = time.Now() + return s.Store.UpsertInstance(*in) +} + +// CreateUser 新建客户端用户并签发证书。 +func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) { + if u.Username == "" { + return nil, fmt.Errorf("username required") + } + in, err := s.Store.GetInstance(u.InstanceID) + if err != nil { + return nil, err + } + if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil { + return nil, fmt.Errorf("user %s already exists", u.Username) + } + u.ID = uuid.NewString() + u.Enabled = true + u.CreatedAt = time.Now() + // 签发证书(Manager 按实例名索引 PKI) + if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil { + return nil, err + } + // CCD + if u.StaticIP != "" { + ccd := "ifconfig-push " + u.StaticIP + " 255.255.255.0\n" + if err := s.Ovm.WriteCCD(in.Name, u.Username, ccd); err != nil { + return nil, err + } + } + // 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载) + if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil { + return nil, err + } + if err := s.Store.UpsertUser(u); err != nil { + return nil, err + } + return &u, nil +} + +// GenerateOVPN 下载/重新生成 .ovpn,remoteHost 由前端传入。 +func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) { + u, err := s.Store.GetUser(userID) + if err != nil { + return "", err + } + in, err := s.Store.GetInstance(u.InstanceID) + if err != nil { + return "", err + } + return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost) +} + +// RevokeUser 吊销用户:禁用 + 标记。 +func (s *Service) RevokeUser(userID string) error { + u, err := s.Store.GetUser(userID) + if err != nil { + return err + } + u.Enabled = false + now := time.Now() + u.RevokedAt = &now + if err := s.Store.UpsertUser(*u); err != nil { + return err + } + // 在 ccd 写入禁用标记 + in, err2 := s.Store.GetInstance(u.InstanceID) + if err2 == nil { + body := "# revoked by manager\n" + _ = s.Ovm.WriteCCD(in.Name, u.Username, body) + } + return nil +} + +// DeleteUser 删除用户及证书。 +func (s *Service) DeleteUser(userID string) error { + u, err := s.Store.GetUser(userID) + if err != nil { + return err + } + in, _ := s.Store.GetInstance(u.InstanceID) + if in != nil { + _ = s.Ovm.DeleteCCD(in.Name, u.Username) + _ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "issued", u.Username+".crt")) + _ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "private", u.Username+".key")) + _ = os.Remove(filepath.Join(s.Cfg.ClientsDir(), in.Name, u.Username+".ovpn")) + } + return s.Store.DeleteUser(userID) +} + +// CertInfos 汇总所有用户证书的过期时间。 +func (s *Service) CertInfos() ([]model.CertInfo, error) { + var out []model.CertInfo + now := time.Now() + for _, in := range s.Store.ListInstances() { + pki := s.Ovm.PKIPath(in.Name) + issuedDir := filepath.Join(pki, "issued") + entries, err := os.ReadDir(issuedDir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".crt") { + continue + } + cn := strings.TrimSuffix(e.Name(), ".crt") + if cn == "server" { + continue + } + notAfter, err := s.Ovm.CertNotAfter(filepath.Join(issuedDir, e.Name())) + if err != nil { + continue + } + out = append(out, model.CertInfo{ + InstanceID: in.ID, + Username: cn, + NotAfter: notAfter, + DaysLeft: int(notAfter.Sub(now).Hours() / 24), + }) + } + } + return out, nil +} + +// ListOnline 解析 status 文件获取在线客户端。 +func (s *Service) ListOnline(instanceID string) ([]openvpn.StatusEntry, error) { + in, err := s.Store.GetInstance(instanceID) + if err != nil { + return nil, err + } + statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log") + return s.Ovm.ParseStatus(statusPath) +} + +// Backup 创建 tar.gz 备份。 +func (s *Service) Backup(note string) (*model.Backup, error) { + if err := os.MkdirAll(s.Cfg.BackupsDir(), 0o755); err != nil { + return nil, err + } + id := uuid.NewString() + ts := time.Now().Format("20060102-150405") + fp := filepath.Join(s.Cfg.BackupsDir(), "backup-"+ts+"-"+id[:8]+".tar.gz") + f, err := os.Create(fp) + if err != nil { + return nil, err + } + defer f.Close() + gz := gzip.NewWriter(f) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + + add := func(rel string) error { + abs := filepath.Join(s.Cfg.DataDir, rel) + return filepath.Walk(abs, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return nil + } + hdr.Name = filepath.ToSlash(filepath.Join(rel, strings.TrimPrefix(path, abs))) + if err := tw.WriteHeader(hdr); err != nil { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + _, _ = tw.Write(data) + return nil + }) + } + for _, sub := range []string{"pki", "instances", "clients"} { + _ = add(sub) + } + b := &model.Backup{ + ID: id, + CreatedAt: time.Now(), + Filename: filepath.Base(fp), + Note: note, + Includes: []string{"pki", "instances", "clients"}, + } + fi, _ := os.Stat(fp) + if fi != nil { + b.Size = fi.Size() + } + _ = s.Store.AddBackup(*b) + return b, nil +} + +// Restore 从备份恢复。会覆盖现有数据。 +func (s *Service) Restore(backupID string) error { + var bk *model.Backup + for _, b := range s.Store.ListBackups() { + if b.ID == backupID { + b := b + bk = &b + break + } + } + if bk == nil { + return fmt.Errorf("backup not found") + } + src := filepath.Join(s.Cfg.BackupsDir(), bk.Filename) + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + var r io.Reader = f + if strings.HasSuffix(src, ".gz") { + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + r = gz + } + tr := tar.NewReader(r) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target := filepath.Join(s.Cfg.DataDir, hdr.Name) + if hdr.FileInfo().IsDir() { + _ = os.MkdirAll(target, 0o755) + continue + } + _ = os.MkdirAll(filepath.Dir(target), 0o755) + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + _, _ = io.Copy(out, tr) + _ = out.Close() + } + return nil +} + +// DeleteBackup 删除备份文件与索引。 +func (s *Service) DeleteBackup(id string) error { + for _, b := range s.Store.ListBackups() { + if b.ID == id { + _ = os.Remove(filepath.Join(s.Cfg.BackupsDir(), b.Filename)) + return s.Store.DeleteBackup(id) + } + } + return fmt.Errorf("not found") +} + +// RandomToken 生成短随机串。 +func RandomToken(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} \ No newline at end of file diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..0635e73 --- /dev/null +++ b/backend/internal/store/store.go @@ -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() +} \ No newline at end of file diff --git a/backend/pkg/openvpn/manager.go b/backend/pkg/openvpn/manager.go new file mode 100644 index 0000000..b48807d --- /dev/null +++ b/backend/pkg/openvpn/manager.go @@ -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\n") + b.WriteString(string(caCrt)) + b.WriteString("\n\n") + b.WriteString("\n\n") + b.WriteString(string(crt)) + b.WriteString("\n\n") + b.WriteString("\n\n") + b.WriteString(string(key)) + b.WriteString("\n\n") + b.WriteString("\n\n") + b.WriteString(string(taKey)) + b.WriteString("\n\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)) +} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d926d4e --- /dev/null +++ b/docs/API.md @@ -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 +``` + +下载 `.ovpn` 也支持通过 query 携带 token: +``` +GET /api/instances//users//ovpn?host=vpn.example.com&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 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= +→ 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=", + "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 条 +- 备份文件不自动清理,需定期手动删除过期备份 \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9051b0a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + OpenVPN 管理控制台 + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4803117 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1877 @@ +{ + "name": "openvpn-manager-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openvpn-manager-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.4.tgz", + "integrity": "sha512-vMKR9tFcLeNrJgFXA3zhUn6YuRKUQW9d0btakBR8U1Iq8MfzkjMOGcgs5de2VgiLldHt69brmuBHpxc3bK4ZgQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.9" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.9.tgz", + "integrity": "sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz", + "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.5.1", + "vue": "^2.7.0 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3897d6e --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..6e5602a --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..06c6a16 --- /dev/null +++ b/frontend/src/api/index.js @@ -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 \ No newline at end of file diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..1663813 --- /dev/null +++ b/frontend/src/assets/main.css @@ -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; } \ No newline at end of file diff --git a/frontend/src/layout/Index.vue b/frontend/src/layout/Index.vue new file mode 100644 index 0000000..b4b06ce --- /dev/null +++ b/frontend/src/layout/Index.vue @@ -0,0 +1,59 @@ + + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..62a748b --- /dev/null +++ b/frontend/src/main.js @@ -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') \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..8448d6b --- /dev/null +++ b/frontend/src/router/index.js @@ -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 \ No newline at end of file diff --git a/frontend/src/views/Audits.vue b/frontend/src/views/Audits.vue new file mode 100644 index 0000000..2960f9d --- /dev/null +++ b/frontend/src/views/Audits.vue @@ -0,0 +1,27 @@ + + \ No newline at end of file diff --git a/frontend/src/views/Backups.vue b/frontend/src/views/Backups.vue new file mode 100644 index 0000000..86dfeec --- /dev/null +++ b/frontend/src/views/Backups.vue @@ -0,0 +1,57 @@ + + \ No newline at end of file diff --git a/frontend/src/views/Certificates.vue b/frontend/src/views/Certificates.vue new file mode 100644 index 0000000..85bfbcf --- /dev/null +++ b/frontend/src/views/Certificates.vue @@ -0,0 +1,27 @@ + + \ No newline at end of file diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..1eacfa4 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,69 @@ + + \ No newline at end of file diff --git a/frontend/src/views/Instances.vue b/frontend/src/views/Instances.vue new file mode 100644 index 0000000..e8ff953 --- /dev/null +++ b/frontend/src/views/Instances.vue @@ -0,0 +1,102 @@ + + \ No newline at end of file diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..5f0dcc7 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,46 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue new file mode 100644 index 0000000..167d5ec --- /dev/null +++ b/frontend/src/views/Logs.vue @@ -0,0 +1,45 @@ + + \ No newline at end of file diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue new file mode 100644 index 0000000..50a4b80 --- /dev/null +++ b/frontend/src/views/Users.vue @@ -0,0 +1,110 @@ + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..ee919b2 --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } +}) \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..3aa4229 --- /dev/null +++ b/scripts/install.sh @@ -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 < 停止并禁用服务" + 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" < "/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 <:${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 \ No newline at end of file diff --git a/systemd/openvpn-manager.service b/systemd/openvpn-manager.service new file mode 100644 index 0000000..6465c4c --- /dev/null +++ b/systemd/openvpn-manager.service @@ -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 \ No newline at end of file