// 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 }