feat: 全协议一键安装 + 国内GitHub镜像加速 + 中英文i18n国际化

后端:
- 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)
- 前端构建零错误
This commit is contained in:
opsx-tools committed 2026-07-23 03:01:46 +08:00
1 parent 1e2f8ef24f
commit 021d012478
25 files changed
+2313 -746

No files matched your search

+37 -6
View File
@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
@@ -610,17 +611,47 @@ func (s *Server) listBinaryStatus(c *gin.Context) {
// installBinary 下载部署协议二进制(FRP / OpenVPN 支持从 GitHub Release 下载)
func (s *Server) installBinary(c *gin.Context) {
name := c.Param("name")
if name != "frp" && name != "openvpn" {
c.JSON(400, gin.H{"error": "仅 FRP / OpenVPN 支持平台自动下载部署,其他协议请使用系统包管理器安装"})
// 合法协议白名单
validProtocols := map[string]bool{
"frp": true, "openvpn": true, "wireguard": true,
"strongswan": true, "ipsec": true, "ikev2": true, "l2tp": true,
}
if !validProtocols[name] {
c.JSON(400, gin.H{"error": fmt.Sprintf("不支持的协议: %s", name)})
return
}
version, err := binmgr.DownloadBinary(name)
// 先检查是否已安装
st := binmgr.CheckBinary(binmgr.ProtocolAlias(name))
if st.Installed {
c.JSON(200, gin.H{"ok": true, "version": st.Version, "message": "已安装", "source": st.Source})
return
}
// 策略1:支持 GitHub 下载的协议(FRP / OpenVPN)先尝试下载
if _, canDownload := binmgr.GetDeploySpec(name); canDownload {
version, err := binmgr.DownloadBinary(name)
if err == nil {
store.Audit(c.GetString("username"), "install_binary", name, version)
c.JSON(200, gin.H{"ok": true, "version": version, "method": "download"})
return
}
// GitHub 下载失败,降级到包管理器
log.Printf("[install] %s GitHub 下载失败(%v),尝试包管理器安装", name, err)
}
// 策略2:系统包管理器一键安装
output, err := binmgr.InstallViaPackageManager(name)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
c.JSON(500, gin.H{"error": err.Error(), "output": output})
return
}
store.Audit(c.GetString("username"), "install_binary", name, version)
c.JSON(200, gin.H{"ok": true, "version": version})
// 安装成功,重新检测
st = binmgr.CheckBinary(binmgr.ProtocolAlias(name))
store.Audit(c.GetString("username"), "install_binary", name, st.Version)
c.JSON(200, gin.H{"ok": true, "version": st.Version, "method": "package_manager", "output": output})
}
// binaryInstallHint 获取协议的系统包安装命令
+279 -36
View File
@@ -2,6 +2,11 @@
// 参考 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 (
@@ -11,6 +16,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
@@ -36,6 +42,130 @@ func dataDir() string {
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 {
@@ -70,6 +200,12 @@ var deploySpecs = map[string]deploySpec{
},
}
// 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]
@@ -77,7 +213,7 @@ func DownloadBinary(name string) (string, error) {
return "", fmt.Errorf("协议 %s 不支持平台下载部署", name)
}
// 1. 获取最新 release
// 1. 获取最新 release(国内自动走镜像)
release, err := fetchLatestRelease(spec.Repo)
if err != nil {
return "", fmt.Errorf("获取 %s 版本失败: %w", name, err)
@@ -89,7 +225,10 @@ func DownloadBinary(name string) (string, error) {
return "", fmt.Errorf("未找到匹配 %s/%s 的 %s 发布包", runtime.GOOS, runtime.GOARCH, name)
}
// 3. 下载 + 解压 + 提取
// 3. 将 asset URL 转为镜像候选列表(国内自动加速)
assetURLs := rewriteAssetURLs(assetURL)
// 4. 下载 + 解压 + 提取
if err := os.MkdirAll(BinDir(), 0o755); err != nil {
return "", err
}
@@ -100,7 +239,7 @@ func DownloadBinary(name string) (string, error) {
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, assetFile)
if err := downloadFile(assetURL, archivePath); err != nil {
if err := downloadFileWithMirror(assetURLs, archivePath); err != nil {
return "", fmt.Errorf("下载失败: %w", err)
}
@@ -120,7 +259,7 @@ func DownloadBinary(name string) (string, error) {
return "", fmt.Errorf("解压失败: %w", err)
}
// 4. 提取二进制到 data/bin/
// 5. 提取二进制到 data/bin/
version := release.TagName
for _, binName := range spec.BinNames {
// 尝试原名 + .exe
@@ -145,11 +284,23 @@ func DownloadBinary(name string) (string, error) {
}
}
// 5. 写版本标记
// 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
@@ -202,35 +353,66 @@ func DetectPackageManager() string {
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": "yum install -y epel-release && yum install -y wireguard-tools",
"dnf": "dnf install -y epel-release && dnf 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": "yum install -y epel-release && yum install -y openvpn",
"dnf": "dnf install -y epel-release && dnf 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": "yum install -y epel-release && yum install -y strongswan",
"dnf": "dnf install -y epel-release && dnf install -y strongswan",
"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{
@@ -256,9 +438,55 @@ func InstallHint(protocol string) map[string]interface{} {
"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 二进制安装状态
@@ -334,42 +562,57 @@ func CheckAll() []BinaryStatus {
// ---- 下载与解压工具 ----
// fetchLatestRelease 获取 GitHub 最新 release,国内自动走镜像加速。
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)
apiPath := fmt.Sprintf("repos/%s/releases/latest", repo)
urls := getAPIURLs(apiPath)
body, err := tryURLs(urls)
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 {
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 {
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
return downloadFileWithMirror([]string{url}, dest)
}
func unzipTo(zipPath, dest string) error {