feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署

UI/UX 优化:
- 登录页分屏布局 + 可访问性修复 + 内联错误提示
- 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮
- Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击
- 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标
- 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流
- 协议品牌SVG图标(ProtocolIcon组件)替换emoji
- 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影

功能增强:
- 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志
- 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置
- 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录
- 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储
- 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf)
- 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示
- 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户
- 404路由 + IPSec对接页可达性修复
- MFA备用码持久化弹窗(替代Toast)
- 轮询可见性守卫 + ECharts resize修复

后端:
- 3张新表: VPNSession/ConnectionLog/TrafficPolicy
- monitor增强: 会话同步+配额检查+异常告警+到期审计
- changePassword统一8位+新旧不同校验
- openvpn-otp文案专业化(移除抄袭感)
- SSE日志流(token降级query参数)
This commit is contained in:
Logan.Li committed 2026-07-23 01:42:36 +08:00
1 parent 58c036c34c
commit 1e2f8ef24f
43 files changed
+6783 -460

No files matched your search

+480
View File
@@ -0,0 +1,480 @@
// Package binmgr 提供协议二进制的下载部署与安装引导能力。
// 参考 opsx-proxy 的 xray_install.go 模式:
// - FRP: 从 GitHub Releases 下载预编译二进制到 data/bin/
// - WireGuard/OpenVPN/StrongSwan: 检测系统包管理器,返回安装命令
package binmgr
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// BinDir 二进制存放目录(data/bin/)
func BinDir() string {
return filepath.Join(dataDir(), "bin")
}
// dataDir 获取数据目录(复用 service.BaseDir 逻辑)
func dataDir() string {
if v := os.Getenv("VPN_DATA_DIR"); v != "" {
return v
}
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "data")
}
return "data"
}
// ---- GitHub Release 下载(FRP / OpenVPN 通用)----
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
var httpClient = &http.Client{Timeout: 5 * time.Minute}
// deploySpec 定义可平台下载部署的协议规格
type deploySpec struct {
Repo string // GitHub 仓库(owner/repo)
BinNames []string // 要提取的二进制名(如 frps/frpc)
AssetHint string // 匹配 asset 的关键词(用于从 release assets 中选对)
WindowsExt string // Windows 下的二进制扩展名
}
// 可下载部署的协议列表
var deploySpecs = map[string]deploySpec{
"frp": {
Repo: "fatedier/frp",
BinNames: []string{"frps", "frpc"},
AssetHint: "", // 匹配逻辑:tag + goos + goarch
},
"openvpn": {
Repo: "OpenVPN/openvpn",
BinNames: []string{"openvpn"},
AssetHint: "", // openvpn release 匹配逻辑略不同,见下方
},
}
// DownloadBinary 通用下载部署:从 GitHub Releases 下载协议二进制到 data/bin/
func DownloadBinary(name string) (string, error) {
spec, ok := deploySpecs[name]
if !ok {
return "", fmt.Errorf("协议 %s 不支持平台下载部署", name)
}
// 1. 获取最新 release
release, err := fetchLatestRelease(spec.Repo)
if err != nil {
return "", fmt.Errorf("获取 %s 版本失败: %w", name, err)
}
// 2. 匹配当前平台的 asset
assetURL, assetFile := matchAsset(name, spec, release)
if assetURL == "" {
return "", fmt.Errorf("未找到匹配 %s/%s 的 %s 发布包", runtime.GOOS, runtime.GOARCH, name)
}
// 3. 下载 + 解压 + 提取
if err := os.MkdirAll(BinDir(), 0o755); err != nil {
return "", err
}
tmpDir, err := os.MkdirTemp("", name+"-dl-*")
if err != nil {
return "", err
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, assetFile)
if err := downloadFile(assetURL, archivePath); err != nil {
return "", fmt.Errorf("下载失败: %w", err)
}
extractDir := filepath.Join(tmpDir, "extracted")
if strings.HasSuffix(assetFile, ".zip") {
err = unzipTo(archivePath, extractDir)
} else if strings.HasSuffix(assetFile, ".tar.gz") || strings.HasSuffix(assetFile, ".tgz") {
err = untargzTo(archivePath, extractDir)
} else {
// 单文件直接复制
err = os.MkdirAll(extractDir, 0o755)
if err == nil {
err = copyFile(archivePath, filepath.Join(extractDir, assetFile))
}
}
if err != nil {
return "", fmt.Errorf("解压失败: %w", err)
}
// 4. 提取二进制到 data/bin/
version := release.TagName
for _, binName := range spec.BinNames {
// 尝试原名 + .exe
candidates := []string{binName}
if runtime.GOOS == "windows" {
candidates = []string{binName + ".exe", binName}
}
for _, cand := range candidates {
src := findFile(extractDir, cand)
if src != "" {
dstName := binName
if runtime.GOOS == "windows" {
dstName += ".exe"
}
dst := filepath.Join(BinDir(), dstName)
if err := copyFile(src, dst); err != nil {
return "", fmt.Errorf("复制 %s 失败: %w", binName, err)
}
os.Chmod(dst, 0o755)
break
}
}
}
// 5. 写版本标记
os.WriteFile(filepath.Join(BinDir(), name+".version"), []byte(version), 0o644)
return version, nil
}
// matchAsset 根据协议名和当前平台从 release assets 中匹配下载 URL
func matchAsset(name string, spec deploySpec, release *githubRelease) (url, file string) {
goos := runtime.GOOS
arch := runtime.GOARCH
// GOARCH 别名
if arch == "amd64" {
arch = "amd64"
}
// 构建匹配关键词
var keywords []string
switch name {
case "frp":
// frp 发布包格式:frp_x.x.x_linux_amd64.tar.gz
keywords = []string{goos + "_" + arch, goos + "-" + arch}
case "openvpn":
// OpenVPN 官方 release 主要是源码 tarball,预编译二进制在社区构建
// 匹配 openvpn-windows-installers 或社区构建的 linux 包
if goos == "windows" {
keywords = []string{"windows", "win", ".exe", ".msi"}
} else {
// Linux 预编译:尝试匹配社区构建或退回源码包
keywords = []string{goos, arch}
}
}
// 遍历 assets 找最佳匹配
for _, a := range release.Assets {
lower := strings.ToLower(a.Name)
// 排除明显不匹配的(如 .asc 签名、.src 源码)
if strings.HasSuffix(lower, ".asc") || strings.HasSuffix(lower, ".sig") {
continue
}
for _, kw := range keywords {
if strings.Contains(lower, kw) {
return a.BrowserDownloadURL, a.Name
}
}
}
return "", ""
}
// ---- 包管理器检测与安装引导 ----
// DetectPackageManager 检测系统包管理器
func DetectPackageManager() string {
for _, pm := range []string{"apt-get", "yum", "dnf", "apk", "pacman"} {
if _, err := exec.LookPath(pm); err == nil {
return pm
}
}
return ""
}
// InstallHint 返回协议在各包管理器下的安装命令。
// 同时返回当前系统检测到的包管理器,供前端高亮推荐。
func InstallHint(protocol string) map[string]interface{} {
detectedPM := DetectPackageManager()
// 嵌套 map: 协议 → 包管理器 → 安装命令
hints := map[string]map[string]string{
"wireguard": {
"apt-get": "apt-get install -y wireguard-tools",
"yum": "yum install -y epel-release && yum install -y wireguard-tools",
"dnf": "dnf install -y epel-release && dnf install -y wireguard-tools",
"apk": "apk add wireguard-tools",
"pacman": "pacman -S --noconfirm wireguard-tools",
},
"openvpn": {
"apt-get": "apt-get install -y openvpn",
"yum": "yum install -y epel-release && yum install -y openvpn",
"dnf": "dnf install -y epel-release && dnf install -y openvpn",
"apk": "apk add openvpn",
"pacman": "pacman -S --noconfirm openvpn",
},
"strongswan": {
"apt-get": "apt-get install -y strongswan strongswan-swanctl",
"yum": "yum install -y epel-release && yum install -y strongswan",
"dnf": "dnf install -y epel-release && dnf install -y strongswan",
"apk": "apk add strongswan",
"pacman": "pacman -S --noconfirm strongswan",
},
}
// IPSec / IKEv2 / L2TP 都依赖 strongswan
displayName := protocol
aliasMap := map[string]string{
"ipsec": "strongswan", "ikev2": "strongswan", "l2tp": "strongswan",
}
if real, ok := aliasMap[protocol]; ok {
protocol = real
}
protoHints, ok := hints[protocol]
if !ok {
return map[string]interface{}{"command": "", "pm": detectedPM, "all": map[string]string{}, "detected": false}
}
// 当前系统推荐的命令
recommendedCmd := protoHints[detectedPM]
if recommendedCmd == "" {
recommendedCmd = protoHints["apt-get"] // 无包管理器时兜底 apt
}
return map[string]interface{}{
"command": recommendedCmd, // 当前系统推荐的命令
"pm": detectedPM, // 检测到的包管理器(空=未检测到)
"detected": detectedPM != "",
"protocol": displayName,
"all_commands": protoHints, // 所有平台的命令,供前端展示
}
}
// ---- 辅助函数 ----
// BinaryStatus 二进制安装状态
type BinaryStatus struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Path string `json:"path"`
Source string `json:"source"` // managed / system / undeployed
Version string `json:"version"`
CanDeploy bool `json:"can_deploy"` // 是否支持平台下载(仅 frp)
InstallCmd string `json:"install_cmd"` // 包管理器安装命令(非 frp)
}
// protocolBins 各协议需要检测的二进制名
var protocolBins = map[string][]string{
"frp": {"frps", "frpc"},
"wireguard": {"wg", "wg-quick"},
"openvpn": {"openvpn"},
"strongswan": {"swanctl", "ipsec"},
}
// CheckBinary 检查单个协议的二进制状态
func CheckBinary(name string) BinaryStatus {
st := BinaryStatus{Name: name}
_, st.CanDeploy = deploySpecs[name] // frp/openvpn 支持平台下载
bins := protocolBins[name]
if len(bins) == 0 {
return st
}
// 1. 检查 data/bin/(平台下载部署的)
for _, binName := range bins {
managed := filepath.Join(BinDir(), binName)
if _, err := os.Stat(managed); err == nil {
st.Installed = true
st.Path = managed
st.Source = "managed"
if v, err := os.ReadFile(filepath.Join(BinDir(), name+".version")); err == nil {
st.Version = strings.TrimSpace(string(v))
}
return st
}
}
// 2. 检查系统 PATH
for _, binName := range bins {
if p, err := exec.LookPath(binName); err == nil {
st.Installed = true
st.Path = p
st.Source = "system"
return st
}
}
// 3. 未安装:返回安装命令(供系统包协议用)
hint := InstallHint(name)
if cmd, ok := hint["command"].(string); ok {
st.InstallCmd = cmd
}
return st
}
// CheckAll 检查所有协议二进制状态
func CheckAll() []BinaryStatus {
return []BinaryStatus{
CheckBinary("frp"),
CheckBinary("wireguard"),
CheckBinary("openvpn"),
CheckBinary("strongswan"),
}
}
// ---- 下载与解压工具 ----
func fetchLatestRelease(repo string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "opsx-vpn")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("GitHub API 返回 %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
func downloadFile(url, dest string) error {
resp, err := httpClient.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("下载返回 %d", resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func unzipTo(zipPath, dest string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
for _, f := range r.File {
path := filepath.Join(dest, f.Name)
if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) {
continue // 防路径穿越
}
if f.FileInfo().IsDir() {
os.MkdirAll(path, 0o755)
continue
}
os.MkdirAll(filepath.Dir(path), 0o755)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
out.Close()
return err
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
return nil
}
func untargzTo(targzPath, dest string) error {
f, err := os.Open(targzPath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
path := filepath.Join(dest, hdr.Name)
if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
os.MkdirAll(path, 0o755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(path), 0o755)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
return err
}
io.Copy(out, tr)
out.Close()
}
}
return nil
}
func findFile(dir, name string) string {
var found string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && info.Name() == name {
found = path
}
return nil
})
return found
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}