后端: - binmgr.go: 新增 detectChina() 国内/海外自动判断(ipinfo.io) - binmgr.go: 国内自动走 gh-proxy.com 镜像加速 GitHub 下载 - binmgr.go: 新增 InstallViaPackageManager() 包管理器一键安装 - binmgr.go: 新增 GetDeploySpec()/ProtocolAlias() 导出函数 - api.go: installBinary 支持全部7个协议(FRP/WireGuard/OpenVPN/IPSec/IKEv2/L2TP/StrongSwan) - api.go: 策略: GitHub下载失败 → 自动降级包管理器安装 前端: - 安装 vue-i18n@9, 支持中英文切换 - 新增 locales/zh-CN.json + en-US.json (548个翻译key, 17个模块) - 新增 i18n.js 配置, localStorage 持久化语言偏好 - Layout.vue 右上角语言切换下拉菜单 - 所有17个Vue文件中文文本转为 $t() 调用 - Element Plus 组件语言跟随切换 - TunnelWizard: 所有协议显示「一键安装」按钮(带loading) - 前端构建零错误
724 lines
21 KiB
Go
724 lines
21 KiB
Go
// 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",
|
||
"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", 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", epelMirror),
|
||
"apk": "apk add openvpn",
|
||
"pacman": "pacman -S --noconfirm openvpn",
|
||
},
|
||
"strongswan": {
|
||
"apt-get": "apt-get install -y strongswan strongswan-swanctl",
|
||
"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 strongswan", 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 strongswan", epelMirror),
|
||
"apk": "apk add strongswan",
|
||
"pacman": "pacman -S --noconfirm strongswan",
|
||
},
|
||
}
|
||
// L2TP 额外需要 xl2tpd
|
||
if protocol == "l2tp" || protocol == "strongswan" {
|
||
// 追加 xl2tpd 包(L2TP 协议必需)
|
||
for pm, cmd := range hints["strongswan"] {
|
||
if pm == "apt-get" {
|
||
hints["strongswan"][pm] = cmd + " xl2tpd"
|
||
} else {
|
||
hints["strongswan"][pm] = strings.Replace(cmd, "strongswan\"", "strongswan xl2tpd\"", 1)
|
||
if !strings.Contains(cmd, "xl2tpd") {
|
||
hints["strongswan"][pm] = cmd + " && " + map[string]string{
|
||
"yum": "yum install -y xl2tpd", "dnf": "dnf install -y xl2tpd",
|
||
"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 镜像
|
||
}
|
||
}
|
||
|
||
// ---- 一键包管理器安装 ----
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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"
|
||
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
|
||
}
|