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参数)
481 lines
13 KiB
Go
481 lines
13 KiB
Go
// 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
|
||
}
|