Files
opsx-vpn/backend/internal/binmgr/binmgr.go
T
Logan.Li 1e2f8ef24f 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参数)
2026-07-23 01:42:36 +08:00

481 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}