// Package binmgr 提供协议二进制的下载部署与安装引导能力。 // 参考 opsx-proxy 的 xray_install.go 模式: // - FRP: 从 GitHub Releases 下载预编译二进制到 data/bin/ // - WireGuard/OpenVPN/StrongSwan: 检测系统包管理器,返回安装命令 // // 国内加速策略(自动判断): // - GitHub API / Release 下载:先直连,失败后依次尝试 ghfast.top / gh-proxy.com 等代理 // - 系统包安装(yum/dnf):EPEL 源默认替换为阿里云镜像,可通过 EPEL_MIRROR 环境变量覆盖 // - 所有镜像可通过 GITHUB_MIRROR 环境变量指定单个代理(跳过自动探测) package binmgr import ( "archive/tar" "archive/zip" "compress/gzip" "encoding/json" "fmt" "io" "log" "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 加速镜像 ---- // chinaGitHubMirror 国内首选镜像(经实测 gh-proxy.com 14MB 仅需 2.6 秒)。 // 海外直连 GitHub 无需代理。 var chinaGitHubMirror = "https://gh-proxy.com" // fallbackGitHubMirrors 备选镜像(gh-proxy.com 不可用时依次尝试)。 var fallbackGitHubMirrors = []string{ "https://ghfast.top", "https://mirror.ghproxy.com", "https://github.moeyy.xyz", } // cachedRegion 缓存区域检测结果,避免每次请求都查询。 var cachedRegion = struct { checked bool isChina bool }{} // detectChina 启动时通过 ipinfo.io 判断服务器是否在中国大陆。 // 结果缓存 24 小时,查询失败默认当作国内处理(安全降级)。 func detectChina() bool { if cachedRegion.checked { return cachedRegion.isChina } client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get("https://ipinfo.io/json") if err != nil { cachedRegion.checked = true cachedRegion.isChina = true // 查询失败默认国内(走镜像) return true } defer resp.Body.Close() var info struct { Country string `json:"country"` } if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { cachedRegion.checked = true cachedRegion.isChina = true return true } cachedRegion.checked = true cachedRegion.isChina = (info.Country == "CN") return cachedRegion.isChina } // getGitHubMirror 返回用户指定的镜像,或空字符串(表示直连)。 // 用户可通过 GITHUB_MIRROR 环境变量强制指定(跳过自动探测)。 func getGitHubMirror() string { if m := os.Getenv("GITHUB_MIRROR"); m != "" { return strings.TrimRight(m, "/") } return "" // 空表示由 getMirrorURLs 自动判断 } // getMirrorURLs 返回一组候选 URL:根据国内/海外自动选择策略。 // githubPath 为 github.com 之后的路径,如 "fatedier/frp/releases/download/v0.61.1/xxx.tar.gz"。 // // 国内:gh-proxy.com(首选)→ 直连(兜底)→ 备选镜像 // 海外:直连 GitHub → gh-proxy.com(兜底) func getMirrorURLs(githubPath string) []string { directURL := "https://github.com/" + githubPath proxyURL := chinaGitHubMirror + "/https://github.com/" + githubPath // 用户显式指定镜像 if m := getGitHubMirror(); m != "" { return []string{m + "/https://github.com/" + githubPath, directURL} } if detectChina() { // 国内:镜像优先,直连兜底 urls := []string{proxyURL, directURL} for _, m := range fallbackGitHubMirrors { urls = append(urls, m+"/https://github.com/"+githubPath) } return urls } // 海外:直连优先,镜像兜底 return []string{directURL, proxyURL} } // getAPIURLs 返回 GitHub API 的候选 URL 列表(同样遵循国内/海外策略)。 func getAPIURLs(apiPath string) []string { directURL := "https://api.github.com/" + apiPath proxyURL := chinaGitHubMirror + "/https://api.github.com/" + apiPath if m := getGitHubMirror(); m != "" { return []string{m + "/https://api.github.com/" + apiPath, directURL} } if detectChina() { return []string{proxyURL, directURL} } return []string{directURL, proxyURL} } // tryURLs 依次请求候选 URL,返回第一个成功的结果。 // 用于 API 请求(返回 body)。 func tryURLs(urls []string) ([]byte, error) { var lastErr error for _, u := range urls { req, _ := http.NewRequest("GET", u, nil) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "opsx-vpn") resp, err := httpClient.Do(req) if err != nil { lastErr = err continue } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = err continue } if resp.StatusCode != 200 { lastErr = fmt.Errorf("%s 返回 %d", u, resp.StatusCode) continue } return body, nil } return nil, fmt.Errorf("所有源均失败,最后错误: %w", lastErr) } // ---- 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 匹配逻辑略不同,见下方 }, } // GetDeploySpec 返回协议的 GitHub 下载规格(供 API 判断是否支持下载) func GetDeploySpec(name string) (deploySpec, bool) { spec, ok := deploySpecs[name] return spec, ok } // 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. 将 asset URL 转为镜像候选列表(国内自动加速) assetURLs := rewriteAssetURLs(assetURL) // 4. 下载 + 解压 + 提取 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 := downloadFileWithMirror(assetURLs, 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) } // 5. 提取二进制到 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 } } } // 6. 写版本标记 os.WriteFile(filepath.Join(BinDir(), name+".version"), []byte(version), 0o644) return version, nil } // rewriteAssetURLs 将 GitHub release asset 原始 URL 转为镜像候选列表。 // 输入如 https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz func rewriteAssetURLs(original string) []string { // 提取 github.com 之后的路径 if idx := strings.Index(original, "github.com/"); idx >= 0 { path := original[idx+len("github.com/"):] return getMirrorURLs(path) } // 非 GitHub URL,直接返回 return []string{original} } // 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 "" } // getEPELMirror 返回国内 EPEL 镜像地址。 // 优先级:EPEL_MIRROR 环境变量 > 阿里云默认 > 官方。 func getEPELMirror() string { if m := os.Getenv("EPEL_MIRROR"); m != "" { return strings.TrimRight(m, "/") } // 默认阿里云 EPEL 镜像(国内最稳定) return "https://mirrors.aliyun.com/epel" } // InstallHint 返回协议在各包管理器下的安装命令。 // 同时返回当前系统检测到的包管理器,供前端高亮推荐。 // 国内策略:yum/dnf 的 EPEL 源默认指向阿里云镜像。 func InstallHint(protocol string) map[string]interface{} { detectedPM := DetectPackageManager() epelMirror := getEPELMirror() // 嵌套 map: 协议 → 包管理器 → 安装命令 // yum/dnf 使用国内 EPEL 镜像加速 hints := map[string]map[string]string{ "wireguard": { "apt-get": "apt-get install -y wireguard-tools", "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y wireguard-tools", epelMirror), "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y wireguard-tools", epelMirror), "apk": "apk add wireguard-tools", "pacman": "pacman -S --noconfirm wireguard-tools", }, "openvpn": { "apt-get": "apt-get install -y openvpn easy-rsa", "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y openvpn easy-rsa", epelMirror), "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y openvpn easy-rsa", epelMirror), "apk": "apk add openvpn easy-rsa", "pacman": "pacman -S --noconfirm openvpn easy-rsa", }, "strongswan": { "apt-get": "apt-get install -y strongswan strongswan-swanctl", "yum": "yum install -y strongswan", "dnf": "dnf install -y strongswan", "apk": "apk add strongswan", "pacman": "pacman -S --noconfirm strongswan", }, } // L2TP 额外需要 xl2tpd。TencentOS 4 的 EPOL 没有该包,需回退到官方源码编译。 if protocol == "l2tp" { for pm, cmd := range hints["strongswan"] { switch pm { case "apt-get": hints["strongswan"][pm] = cmd + " xl2tpd" case "yum", "dnf": hints["strongswan"][pm] = cmd + " && " + xl2tpdInstallCommand(pm) default: hints["strongswan"][pm] = cmd + " && " + map[string]string{ "apk": "apk add xl2tpd", "pacman": "pacman -S --noconfirm xl2tpd", }[pm] } } } // 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, // 所有平台的命令,供前端展示 "epel_mirror": epelMirror, // 当前使用的 EPEL 镜像 } } func xl2tpdInstallCommand(pm string) string { pkg := pm return fmt.Sprintf(`if %s list --available xl2tpd >/dev/null 2>&1; then %s install -y xl2tpd; else %s install -y gcc make libpcap-devel git && workdir=$(mktemp -d) && git clone --depth 1 https://github.com/xelerance/xl2tpd.git "$workdir/xl2tpd" && make -C "$workdir/xl2tpd" && install -m 0755 "$workdir/xl2tpd/xl2tpd" /usr/local/sbin/xl2tpd && rm -rf "$workdir"; fi`, pkg, pkg, pkg) } // ---- 一键包管理器安装 ---- // InstallViaPackageManager 通过系统包管理器一键安装协议依赖。 // 返回安装输出和检测到的二进制路径。 func InstallViaPackageManager(protocol string) (output string, err error) { pm := DetectPackageManager() if pm == "" { return "", fmt.Errorf("未检测到系统包管理器(apt/yum/dnf/apk/pacman)") } // 获取安装命令 hint := InstallHint(protocol) cmd, _ := hint["command"].(string) if cmd == "" { return "", fmt.Errorf("协议 %s 无可用安装命令", protocol) } // 执行安装(bash -c 包含管道等) log.Printf("[binmgr] 执行安装命令: %s", cmd) out, err := exec.Command("bash", "-c", cmd).CombinedOutput() output = string(out) if err != nil { return output, fmt.Errorf("安装失败: %w\n%s", err, output) } // 安装后重新检测 st := CheckBinary(ProtocolAlias(protocol)) if !st.Installed { return output, fmt.Errorf("安装命令执行成功但未检测到二进制,可能需要手动配置") } return output, nil } // easyRSAAvailable 判断 easy-rsa 是否可用。 // 兼容两种安装形态:包管理器装了 /usr/share/easy-rsa/easyrsa(常不在 PATH), // 或用户自行软链到 PATH。 func easyRSAAvailable() bool { if _, err := exec.LookPath("easyrsa"); err == nil { return true } for _, pattern := range []string{ "/usr/share/easy-rsa/easyrsa", "/usr/share/easy-rsa/*/easyrsa", "/usr/local/share/easy-rsa/easyrsa", } { if paths, _ := filepath.Glob(pattern); len(paths) > 0 { return true } } return false } // ProtocolAlias 返回协议的实际二进制检测名(导出供 API 使用) func ProtocolAlias(protocol string) string { aliasMap := map[string]string{ "ipsec": "strongswan", "ikev2": "strongswan", "l2tp": "strongswan", } if real, ok := aliasMap[protocol]; ok { return real } return protocol } // ---- 辅助函数 ---- // 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" // OpenVPN 依赖 easy-rsa 生成 PKI;缺失时标记未就绪, // 否则页面显示「已就绪」但建隧道时读 ca.crt 会失败。 if name == "openvpn" && !easyRSAAvailable() { st.Installed = false st.Path = p st.Version = "" return st } 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"), } } // ---- 下载与解压工具 ---- // fetchLatestRelease 获取 GitHub 最新 release,国内自动走镜像加速。 func fetchLatestRelease(repo string) (*githubRelease, error) { apiPath := fmt.Sprintf("repos/%s/releases/latest", repo) urls := getAPIURLs(apiPath) body, err := tryURLs(urls) if err != nil { return nil, err } var release githubRelease if err := json.Unmarshal(body, &release); err != nil { return nil, err } return &release, nil } // downloadFileWithMirror 依次尝试候选 URL 下载文件到 dest。 func downloadFileWithMirror(urls []string, dest string) error { var lastErr error for _, u := range urls { resp, err := httpClient.Get(u) if err != nil { lastErr = err continue } if resp.StatusCode != 200 { resp.Body.Close() lastErr = fmt.Errorf("%s 返回 %d", u, resp.StatusCode) continue } out, err := os.Create(dest) if err != nil { resp.Body.Close() lastErr = err continue } _, err = io.Copy(out, resp.Body) out.Close() resp.Body.Close() if err != nil { lastErr = err continue } return nil // 成功 } return fmt.Errorf("所有下载源均失败,最后错误: %w", lastErr) } // downloadFile 单 URL 下载(保留向后兼容)。 func downloadFile(url, dest string) error { return downloadFileWithMirror([]string{url}, dest) } 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 }