mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 07:56:37 +08:00
8b9cdef0e4
* docs: add GitHub proxy config design spec * docs: add GitHub proxy config implementation plan * feat(backend): use configurable github proxy for node upgrades * feat(backend): use configurable github proxy for node install command * feat(frontend): add github proxy config settings * feat(script): support configurable github proxy in installer flows Honor custom GitHub mirror settings across interactive and env-driven installer/update paths so script downloads match the panel configuration. Add shell regressions to lock down proxy prompting, URL recomputation, and non-interactive fallback behavior.
431 lines
11 KiB
Go
431 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go-backend/internal/http/response"
|
|
)
|
|
|
|
const (
|
|
githubRepo = "Sagit-chu/flvx"
|
|
githubAPIBase = "https://api.github.com"
|
|
githubHTMLBase = "https://github.com"
|
|
upgradeTimeout = 5 * time.Minute
|
|
batchWorkers = 5
|
|
|
|
releaseChannelStable = "stable"
|
|
releaseChannelDev = "dev"
|
|
|
|
defaultGithubProxyEnabled = true
|
|
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
|
|
)
|
|
|
|
var (
|
|
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
|
|
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
|
|
)
|
|
|
|
type githubRelease struct {
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
PublishedAt string `json:"published_at"`
|
|
Prerelease bool `json:"prerelease"`
|
|
Draft bool `json:"draft"`
|
|
}
|
|
|
|
func normalizeReleaseChannel(channel string) string {
|
|
switch strings.ToLower(strings.TrimSpace(channel)) {
|
|
case releaseChannelDev:
|
|
return releaseChannelDev
|
|
default:
|
|
return releaseChannelStable
|
|
}
|
|
}
|
|
|
|
func releaseChannelFromTag(tag string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(tag))
|
|
if normalized == "" {
|
|
return releaseChannelDev
|
|
}
|
|
if testKeywordPattern.MatchString(normalized) {
|
|
return releaseChannelDev
|
|
}
|
|
if stableVersionPattern.MatchString(normalized) {
|
|
return releaseChannelStable
|
|
}
|
|
|
|
return releaseChannelDev
|
|
}
|
|
|
|
func releaseChannelLabel(channel string) string {
|
|
if normalizeReleaseChannel(channel) == releaseChannelDev {
|
|
return "测试版"
|
|
}
|
|
|
|
return "正式版"
|
|
}
|
|
|
|
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
|
|
enabled = defaultGithubProxyEnabled
|
|
proxyURL = defaultGithubProxyURL
|
|
|
|
if h == nil || h.repo == nil {
|
|
return
|
|
}
|
|
|
|
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
|
|
enabled = enabledCfg.Value != "false"
|
|
}
|
|
|
|
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
|
|
proxyURL = strings.TrimSpace(urlCfg.Value)
|
|
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
|
|
proxyURL = "https://" + proxyURL
|
|
}
|
|
proxyURL = strings.TrimSuffix(proxyURL, "/")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
|
|
enabled, proxyURL := h.getGithubProxyConfig()
|
|
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
|
|
|
|
if enabled {
|
|
return fmt.Sprintf("%s/%s", proxyURL, base)
|
|
}
|
|
return base
|
|
}
|
|
|
|
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
|
|
if perPage <= 0 {
|
|
perPage = 20
|
|
}
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var releases []githubRelease
|
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
|
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
|
|
}
|
|
|
|
return releases, nil
|
|
}
|
|
|
|
func resolveLatestReleaseByChannel(channel string) (string, error) {
|
|
normalizedChannel := normalizeReleaseChannel(channel)
|
|
releases, err := fetchGitHubReleases(50)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, r := range releases {
|
|
if r.Draft {
|
|
continue
|
|
}
|
|
tag := strings.TrimSpace(r.TagName)
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
if releaseChannelFromTag(tag) == normalizedChannel {
|
|
return tag, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
|
}
|
|
|
|
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
ID int64 `json:"id"`
|
|
Version string `json:"version"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
if err := decodeJSON(r.Body, &req); err != nil {
|
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
|
return
|
|
}
|
|
if req.ID <= 0 {
|
|
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
|
return
|
|
}
|
|
|
|
channel := normalizeReleaseChannel(req.Channel)
|
|
version := strings.TrimSpace(req.Version)
|
|
if version == "" {
|
|
var err error
|
|
version, err = resolveLatestReleaseByChannel(channel)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
|
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
|
|
|
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
|
|
"downloadUrl": downloadURL,
|
|
"checksumUrl": checksumURL,
|
|
}, upgradeTimeout)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
|
|
return
|
|
}
|
|
h.markNodePendingUpgradeRedeploy(req.ID)
|
|
|
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
|
"version": version,
|
|
"message": result.Message,
|
|
}))
|
|
}
|
|
|
|
func resolveLatestRelease() (string, error) {
|
|
return resolveLatestReleaseByChannel(releaseChannelStable)
|
|
}
|
|
|
|
func resolveLatestReleaseAPI() (string, error) {
|
|
return resolveLatestReleaseByChannel(releaseChannelStable)
|
|
}
|
|
|
|
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
IDs []int64 `json:"ids"`
|
|
Version string `json:"version"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
|
return
|
|
}
|
|
if len(req.IDs) == 0 {
|
|
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
|
|
return
|
|
}
|
|
|
|
channel := normalizeReleaseChannel(req.Channel)
|
|
version := strings.TrimSpace(req.Version)
|
|
if version == "" {
|
|
var err error
|
|
version, err = resolveLatestReleaseByChannel(channel)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
|
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
|
|
|
type upgradeResult struct {
|
|
ID int64 `json:"id"`
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
results := make([]upgradeResult, len(req.IDs))
|
|
sem := make(chan struct{}, batchWorkers)
|
|
var wg sync.WaitGroup
|
|
|
|
for i, id := range req.IDs {
|
|
wg.Add(1)
|
|
go func(index int, nodeID int64) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
result, err := h.wsServer.SendCommand(nodeID, "UpgradeAgent", map[string]interface{}{
|
|
"downloadUrl": downloadURL,
|
|
"checksumUrl": checksumURL,
|
|
}, upgradeTimeout)
|
|
if err != nil {
|
|
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
|
|
return
|
|
}
|
|
h.markNodePendingUpgradeRedeploy(nodeID)
|
|
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
|
|
}(i, id)
|
|
}
|
|
wg.Wait()
|
|
|
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
|
"version": version,
|
|
"results": results,
|
|
}))
|
|
}
|
|
|
|
func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Channel string `json:"channel"`
|
|
}
|
|
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
|
return
|
|
}
|
|
|
|
channel := normalizeReleaseChannel(req.Channel)
|
|
|
|
releases, err := fetchGitHubReleases(50)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
|
return
|
|
}
|
|
|
|
type releaseItem struct {
|
|
Version string `json:"version"`
|
|
Name string `json:"name"`
|
|
PublishedAt string `json:"publishedAt"`
|
|
Prerelease bool `json:"prerelease"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
|
|
items := make([]releaseItem, 0, len(releases))
|
|
for _, r := range releases {
|
|
if r.Draft {
|
|
continue
|
|
}
|
|
tag := strings.TrimSpace(r.TagName)
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
itemChannel := releaseChannelFromTag(tag)
|
|
if itemChannel != channel {
|
|
continue
|
|
}
|
|
items = append(items, releaseItem{
|
|
Version: tag,
|
|
Name: r.Name,
|
|
PublishedAt: r.PublishedAt,
|
|
Prerelease: itemChannel == releaseChannelDev,
|
|
Channel: itemChannel,
|
|
})
|
|
}
|
|
|
|
response.WriteJSON(w, response.OK(items))
|
|
}
|
|
|
|
func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
if err := decodeJSON(r.Body, &req); err != nil {
|
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
|
return
|
|
}
|
|
if req.ID <= 0 {
|
|
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
|
return
|
|
}
|
|
|
|
result, err := h.wsServer.SendCommand(req.ID, "RollbackAgent", map[string]interface{}{}, 30*time.Second)
|
|
if err != nil {
|
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("回退失败: %v", err)))
|
|
return
|
|
}
|
|
|
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
|
"message": result.Message,
|
|
}))
|
|
}
|
|
|
|
func (h *Handler) markNodePendingUpgradeRedeploy(nodeID int64) {
|
|
if h == nil || nodeID <= 0 {
|
|
return
|
|
}
|
|
h.upgradeMu.Lock()
|
|
h.pendingUpgradeRedeploy[nodeID] = struct{}{}
|
|
h.upgradeMu.Unlock()
|
|
}
|
|
|
|
func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
|
|
if h == nil || nodeID <= 0 {
|
|
return false
|
|
}
|
|
h.upgradeMu.Lock()
|
|
_, ok := h.pendingUpgradeRedeploy[nodeID]
|
|
if ok {
|
|
delete(h.pendingUpgradeRedeploy, nodeID)
|
|
}
|
|
h.upgradeMu.Unlock()
|
|
return ok
|
|
}
|
|
|
|
func (h *Handler) onNodeOnline(nodeID int64) {
|
|
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
|
|
return
|
|
}
|
|
h.redeployNodeRuntimeAfterUpgrade(nodeID)
|
|
}
|
|
|
|
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
|
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
|
|
if err != nil {
|
|
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
|
|
return
|
|
}
|
|
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
|
|
if err != nil {
|
|
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
|
|
return
|
|
}
|
|
|
|
tunnelFailed := make(map[int64]struct{})
|
|
for _, tunnelID := range tunnelIDs {
|
|
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
|
tunnelFailed[tunnelID] = struct{}{}
|
|
fmt.Printf("post-upgrade redeploy: tunnel %d failed on node %d: %v\n", tunnelID, nodeID, err)
|
|
}
|
|
}
|
|
|
|
for _, forwardID := range forwardIDs {
|
|
forward, getErr := h.getForwardRecord(forwardID)
|
|
if getErr != nil || forward == nil {
|
|
continue
|
|
}
|
|
if _, skipped := tunnelFailed[forward.TunnelID]; skipped {
|
|
continue
|
|
}
|
|
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
|
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
|
|
}
|
|
}
|
|
}
|