mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 04:20:50 +08:00
- Added `GitTokenStore` to handle token storage and metadata using Git as a backing storage. - Implemented methods for initialization, save, retrieval, listing, and deletion of auth files. - Updated `go.mod` and `go.sum` to include new dependencies for Git integration. - Integrated support for Git-backed configuration via `GitTokenStore`. - Updated server logic to clone, initialize, and manage configurations from Git repositories. - Added helper functions for verifying and synchronizing configuration files. - Improved error handling and contextual logging for Git operations. - Modified Dockerfile to include `config.example.yaml` for initial setup. - Added `gitCommitter` interface to handle Git-based commit and push operations. - Configured `Watcher` to detect and leverage Git-backed token stores. - Implemented `commitConfigAsync` and `commitAuthAsync` methods for asynchronous change synchronization. - Enhanced `GitTokenStore` with `CommitPaths` method to support selective file commits.
285 lines
7.4 KiB
Go
285 lines
7.4 KiB
Go
package managementasset
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
|
|
managementAssetName = "management.html"
|
|
httpUserAgent = "CLIProxyAPI-management-updater"
|
|
updateCheckInterval = 3 * time.Hour
|
|
)
|
|
|
|
// ManagementFileName exposes the control panel asset filename.
|
|
const ManagementFileName = managementAssetName
|
|
|
|
var (
|
|
lastUpdateCheckMu sync.Mutex
|
|
lastUpdateCheckTime time.Time
|
|
)
|
|
|
|
func newHTTPClient(proxyURL string) *http.Client {
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
|
|
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}
|
|
util.SetProxy(sdkCfg, client)
|
|
|
|
return client
|
|
}
|
|
|
|
type releaseAsset struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
type releaseResponse struct {
|
|
Assets []releaseAsset `json:"assets"`
|
|
}
|
|
|
|
// StaticDir resolves the directory that stores the management control panel asset.
|
|
func StaticDir(configFilePath string) string {
|
|
configFilePath = strings.TrimSpace(configFilePath)
|
|
if configFilePath == "" {
|
|
return ""
|
|
}
|
|
|
|
base := filepath.Dir(configFilePath)
|
|
fileInfo, err := os.Stat(configFilePath)
|
|
if err == nil {
|
|
if fileInfo.IsDir() {
|
|
base = configFilePath
|
|
}
|
|
}
|
|
|
|
return filepath.Join(base, "static")
|
|
}
|
|
|
|
// FilePath resolves the absolute path to the management control panel asset.
|
|
func FilePath(configFilePath string) string {
|
|
dir := StaticDir(configFilePath)
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(dir, ManagementFileName)
|
|
}
|
|
|
|
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
|
// The function is designed to run in a background goroutine and will never panic.
|
|
// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes.
|
|
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
staticDir = strings.TrimSpace(staticDir)
|
|
if staticDir == "" {
|
|
log.Debug("management asset sync skipped: empty static directory")
|
|
return
|
|
}
|
|
|
|
// Rate limiting: check only once every 3 hours
|
|
lastUpdateCheckMu.Lock()
|
|
now := time.Now()
|
|
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
|
|
if timeSinceLastCheck < updateCheckInterval {
|
|
lastUpdateCheckMu.Unlock()
|
|
log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval)
|
|
return
|
|
}
|
|
lastUpdateCheckTime = now
|
|
lastUpdateCheckMu.Unlock()
|
|
|
|
if err := os.MkdirAll(staticDir, 0o755); err != nil {
|
|
log.WithError(err).Warn("failed to prepare static directory for management asset")
|
|
return
|
|
}
|
|
|
|
client := newHTTPClient(proxyURL)
|
|
|
|
localPath := filepath.Join(staticDir, managementAssetName)
|
|
localHash, err := fileSHA256(localPath)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
log.WithError(err).Debug("failed to read local management asset hash")
|
|
}
|
|
localHash = ""
|
|
}
|
|
|
|
asset, remoteHash, err := fetchLatestAsset(ctx, client)
|
|
if err != nil {
|
|
log.WithError(err).Warn("failed to fetch latest management release information")
|
|
return
|
|
}
|
|
|
|
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
|
log.Debug("management asset is already up to date")
|
|
return
|
|
}
|
|
|
|
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
|
if err != nil {
|
|
log.WithError(err).Warn("failed to download management asset")
|
|
return
|
|
}
|
|
|
|
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
|
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
|
}
|
|
|
|
if err = atomicWriteFile(localPath, data); err != nil {
|
|
log.WithError(err).Warn("failed to update management asset on disk")
|
|
return
|
|
}
|
|
|
|
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
|
|
}
|
|
|
|
func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("create release request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("User-Agent", httpUserAgent)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("execute release request: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, "", fmt.Errorf("unexpected release status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var release releaseResponse
|
|
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
|
return nil, "", fmt.Errorf("decode release response: %w", err)
|
|
}
|
|
|
|
for i := range release.Assets {
|
|
asset := &release.Assets[i]
|
|
if strings.EqualFold(asset.Name, managementAssetName) {
|
|
remoteHash := parseDigest(asset.Digest)
|
|
return asset, remoteHash, nil
|
|
}
|
|
}
|
|
|
|
return nil, "", fmt.Errorf("management asset %s not found in latest release", managementAssetName)
|
|
}
|
|
|
|
func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) ([]byte, string, error) {
|
|
if strings.TrimSpace(downloadURL) == "" {
|
|
return nil, "", fmt.Errorf("empty download url")
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("create download request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", httpUserAgent)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("execute download request: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("read download body: %w", err)
|
|
}
|
|
|
|
sum := sha256.Sum256(data)
|
|
return data, hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func fileSHA256(path string) (string, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = file.Close()
|
|
}()
|
|
|
|
h := sha256.New()
|
|
if _, err = io.Copy(h, file); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func atomicWriteFile(path string, data []byte) error {
|
|
tmpFile, err := os.CreateTemp(filepath.Dir(path), "management-*.html")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tmpName := tmpFile.Name()
|
|
defer func() {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpName)
|
|
}()
|
|
|
|
if _, err = tmpFile.Write(data); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tmpFile.Chmod(0o644); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tmpFile.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = os.Rename(tmpName, path); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseDigest(digest string) string {
|
|
digest = strings.TrimSpace(digest)
|
|
if digest == "" {
|
|
return ""
|
|
}
|
|
|
|
if idx := strings.Index(digest, ":"); idx >= 0 {
|
|
digest = digest[idx+1:]
|
|
}
|
|
|
|
return strings.ToLower(strings.TrimSpace(digest))
|
|
}
|