mirror of
https://github.com/VaalaCat/frp-panel.git
synced 2026-04-22 15:07:30 +08:00
feat: ota upgrade command
This commit is contained in:
@@ -1,271 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/VaalaCat/frp-panel/utils"
|
||||
"github.com/VaalaCat/frp-panel/utils/logger"
|
||||
)
|
||||
|
||||
// UpgradeOptions 定义自助升级所需的参数
|
||||
type UpgradeOptions struct {
|
||||
// Version 指定要升级的版本,默认为 latest
|
||||
Version string
|
||||
// GithubProxy 形如 https://ghfast.top/ 的前缀,会直接拼在下载链接前
|
||||
GithubProxy string
|
||||
// HTTPProxy 传递给 req/v3,用于走 HTTP/HTTPS 代理
|
||||
HTTPProxy string
|
||||
// TargetPath 需要覆盖的可执行文件路径,默认为当前运行的 frp-panel 路径
|
||||
TargetPath string
|
||||
// Backup 覆盖前是否备份旧文件,默认 true
|
||||
Backup bool
|
||||
// StopService 升级前是否尝试停止 systemd 服务,避免二进制被占用
|
||||
StopService bool
|
||||
// ServiceName systemd 服务名,默认 frpp
|
||||
ServiceName string
|
||||
// UseGithubProxy 仅当显式开启时才使用 Github 代理
|
||||
UseGithubProxy bool
|
||||
}
|
||||
|
||||
// UpgradeSelf 下载并替换当前可执行文件
|
||||
func UpgradeSelf(ctx context.Context, opt UpgradeOptions) (err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if err := opt.fillDefaults(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
asset, err := detectAssetName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
backupPath string
|
||||
serviceWasActive bool
|
||||
)
|
||||
|
||||
if opt.StopService && len(opt.ServiceName) > 0 {
|
||||
serviceWasActive, err = stopServiceIfActive(ctx, opt.ServiceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// 失败回滚
|
||||
if err != nil && len(backupPath) > 0 {
|
||||
if rErr := restoreBackup(ctx, backupPath, opt.TargetPath); rErr != nil {
|
||||
logger.Logger(ctx).Warnf("failed to restore backup, please check manually: %v", rErr)
|
||||
}
|
||||
}
|
||||
// 按原状态决定是否重启
|
||||
if serviceWasActive {
|
||||
if startErr := controlService(ctx, "start", opt.ServiceName); startErr != nil {
|
||||
logger.Logger(ctx).Warnf("failed to start service after upgrade, please check manually: %v", startErr)
|
||||
if err == nil {
|
||||
err = startErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
downloadURL := fmt.Sprintf("https://github.com/VaalaCat/frp-panel/releases/download/%s/%s", opt.Version, asset)
|
||||
if opt.UseGithubProxy && len(opt.GithubProxy) > 0 {
|
||||
downloadURL = fmt.Sprintf("%s/%s", strings.TrimRight(opt.GithubProxy, "/"), downloadURL)
|
||||
}
|
||||
|
||||
logger.Logger(ctx).Infof("start downloading version [%s], url: %s", opt.Version, downloadURL)
|
||||
tmpPath, err := utils.DownloadFile(ctx, downloadURL, opt.HTTPProxy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
logger.Logger(ctx).Warnf("set file permission failed: %v", err)
|
||||
}
|
||||
|
||||
if err := utils.EnsureDirectoryExists(opt.TargetPath); err != nil {
|
||||
return fmt.Errorf("ensure target directory failed: %w", err)
|
||||
}
|
||||
|
||||
if opt.Backup {
|
||||
if backupPath, err = backupExisting(ctx, opt.TargetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := replaceFile(tmpPath, opt.TargetPath); err != nil {
|
||||
return fmt.Errorf("replace executable failed: %w", err)
|
||||
}
|
||||
|
||||
logger.Logger(ctx).Infof("frp-panel upgraded successfully, path: %s", opt.TargetPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (opt *UpgradeOptions) fillDefaults(ctx context.Context) error {
|
||||
if len(opt.Version) == 0 {
|
||||
opt.Version = "latest"
|
||||
}
|
||||
if len(opt.TargetPath) == 0 {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取当前执行文件失败: %w", err)
|
||||
}
|
||||
// 优先解析符号链接,确保替换真实文件
|
||||
if realPath, err := filepath.EvalSymlinks(exePath); err == nil && len(realPath) > 0 {
|
||||
exePath = realPath
|
||||
}
|
||||
opt.TargetPath = exePath
|
||||
}
|
||||
|
||||
if absPath, err := filepath.Abs(opt.TargetPath); err == nil {
|
||||
opt.TargetPath = absPath
|
||||
}
|
||||
|
||||
if opt.StopService && len(opt.ServiceName) == 0 {
|
||||
opt.ServiceName = "frpp"
|
||||
}
|
||||
|
||||
// 允许用户显式传空字符串来禁用代理
|
||||
opt.GithubProxy = strings.TrimSpace(opt.GithubProxy)
|
||||
opt.HTTPProxy = strings.TrimSpace(opt.HTTPProxy)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectAssetName() (string, error) {
|
||||
osName := runtime.GOOS
|
||||
arch := runtime.GOARCH
|
||||
|
||||
unameArch := arch
|
||||
if runtime.GOOS != "windows" {
|
||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
unameArch = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
|
||||
switch osName {
|
||||
case "linux":
|
||||
switch unameArch {
|
||||
case "x86_64", "amd64":
|
||||
return "frp-panel-linux-amd64", nil
|
||||
case "aarch64", "arm64":
|
||||
return "frp-panel-linux-arm64", nil
|
||||
case "armv7l":
|
||||
return "frp-panel-linux-armv7l", nil
|
||||
case "armv6l":
|
||||
return "frp-panel-linux-armv6l", nil
|
||||
}
|
||||
case "darwin":
|
||||
switch unameArch {
|
||||
case "x86_64", "amd64":
|
||||
return "frp-panel-darwin-amd64", nil
|
||||
case "arm64":
|
||||
return "frp-panel-darwin-arm64", nil
|
||||
}
|
||||
case "windows":
|
||||
switch arch {
|
||||
case "amd64":
|
||||
return "frp-panel-windows-amd64.exe", nil
|
||||
case "arm64":
|
||||
return "frp-panel-windows-arm64.exe", nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("暂不支持的系统/架构: %s %s", osName, unameArch)
|
||||
}
|
||||
|
||||
func backupExisting(ctx context.Context, path string) (string, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("stat existing binary failed: %w", err)
|
||||
}
|
||||
|
||||
backupPath := path + ".bak"
|
||||
_ = os.Remove(backupPath)
|
||||
|
||||
if err := copyFile(path, backupPath); err != nil {
|
||||
return "", fmt.Errorf("backup existing binary failed: %w", err)
|
||||
}
|
||||
|
||||
logger.Logger(ctx).Infof("backup created at: %s", backupPath)
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func replaceFile(src, dst string) error {
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func restoreBackup(ctx context.Context, backupPath, target string) error {
|
||||
if len(backupPath) == 0 {
|
||||
return nil
|
||||
}
|
||||
logger.Logger(ctx).Infof("attempt to restore from backup: %s -> %s", backupPath, target)
|
||||
return replaceFile(backupPath, target)
|
||||
}
|
||||
|
||||
func controlService(ctx context.Context, action, serviceName string) error {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", action, serviceName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s %s failed: %w, output: %s", action, serviceName, err, string(output))
|
||||
}
|
||||
logger.Logger(ctx).Infof("systemctl %s %s success", action, serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopServiceIfActive(ctx context.Context, serviceName string) (bool, error) {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", serviceName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// 非 active,无需停
|
||||
logger.Logger(ctx).Infof("service %s is not active, skip stop", serviceName)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := controlService(ctx, "stop", serviceName); err != nil {
|
||||
return false, err
|
||||
}
|
||||
logger.Logger(ctx).Infof("service %s stopped, ready to upgrade", serviceName)
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build !windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func detectAssetName() (string, error) {
|
||||
osName := runtime.GOOS
|
||||
machine := unameMachine()
|
||||
if len(machine) == 0 {
|
||||
// fallback
|
||||
machine = runtime.GOARCH
|
||||
}
|
||||
|
||||
switch osName {
|
||||
case "linux":
|
||||
switch machine {
|
||||
case "x86_64", "amd64":
|
||||
return "frp-panel-linux-amd64", nil
|
||||
case "aarch64", "arm64":
|
||||
return "frp-panel-linux-arm64", nil
|
||||
case "armv7l":
|
||||
return "frp-panel-linux-armv7l", nil
|
||||
case "armv6l":
|
||||
return "frp-panel-linux-armv6l", nil
|
||||
}
|
||||
case "darwin":
|
||||
switch machine {
|
||||
case "x86_64", "amd64":
|
||||
return "frp-panel-darwin-amd64", nil
|
||||
case "arm64":
|
||||
return "frp-panel-darwin-arm64", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("暂不支持的系统/架构: %s %s", osName, machine)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func detectAssetName() (string, error) {
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return "frp-panel-windows-amd64.exe", nil
|
||||
case "arm64":
|
||||
return "frp-panel-windows-arm64.exe", nil
|
||||
default:
|
||||
return "", fmt.Errorf("暂不支持的系统/架构: %s %s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func applyDetachAttr(attr *os.ProcAttr) {
|
||||
if attr == nil {
|
||||
return
|
||||
}
|
||||
attr.Sys = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func applyDetachAttr(attr *os.ProcAttr) {
|
||||
if attr == nil {
|
||||
return
|
||||
}
|
||||
attr.Sys = &syscall.SysProcAttr{
|
||||
// 0x00000008: DETACHED_PROCESS(避免引入额外 windows 依赖,同时满足“不使用 exec 包”)
|
||||
CreationFlags: 0x00000008,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildDownloadURL(opt Options) (string, error) {
|
||||
if u := strings.TrimSpace(opt.DownloadURL); len(u) > 0 {
|
||||
return u, nil
|
||||
}
|
||||
version := strings.TrimSpace(opt.Version)
|
||||
if len(version) == 0 {
|
||||
version = "latest"
|
||||
}
|
||||
|
||||
asset, err := detectAssetName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
url := fmt.Sprintf("https://github.com/VaalaCat/frp-panel/releases/download/%s/%s", version, asset)
|
||||
if opt.UseGithubProxy && len(strings.TrimSpace(opt.GithubProxy)) > 0 {
|
||||
url = fmt.Sprintf("%s/%s", strings.TrimRight(strings.TrimSpace(opt.GithubProxy), "/"), url)
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type unlockFn func()
|
||||
|
||||
// Windows 简化处理:用 O_EXCL 创建锁文件
|
||||
// 说明:Windows 不支持 unix flock;进程异常退出可能残留 lock 文件,必要时可手动删除。
|
||||
func lock(workDir string) (unlockFn, error) {
|
||||
if len(workDir) == 0 {
|
||||
workDir = defaultWorkDir()
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create work dir failed: %w", err)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(workDir, "upgrade.lock")
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upgrade already in progress (lock exists): %w", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
return func() { _ = os.Remove(lockPath) }, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build !windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type unlockFn func()
|
||||
|
||||
// Unix 使用 flock:进程异常退出/被 SIGINT 杀死时锁会自动释放,不会残留“死锁文件”
|
||||
func lock(workDir string) (unlockFn, error) {
|
||||
if len(workDir) == 0 {
|
||||
workDir = defaultWorkDir()
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create work dir failed: %w", err)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(workDir, "upgrade.lock")
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open lock file failed: %w", err)
|
||||
}
|
||||
|
||||
// LOCK_NB:如果已有升级在跑,直接返回错误(不会阻塞)
|
||||
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("upgrade already in progress: %w", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
_ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package upgrade
|
||||
|
||||
// Options 升级参数(与 CLI flags 对应)
|
||||
type Options struct {
|
||||
// Version 目标版本,默认 latest(仅当 DownloadURL 为空时生效)
|
||||
Version string
|
||||
|
||||
// DownloadURL 自定义下载地址(最高优先级)。若非空,则直接下载该 URL
|
||||
DownloadURL string
|
||||
|
||||
// GithubProxy GitHub 代理前缀,例如 https://ghfast.top/(仅当 UseGithubProxy=true 且 DownloadURL 为空时生效)
|
||||
GithubProxy string
|
||||
|
||||
// UseGithubProxy 是否启用 GithubProxy
|
||||
UseGithubProxy bool
|
||||
|
||||
// HTTPProxy 下载用的 http/https 代理(透传给 req/v3)
|
||||
HTTPProxy string
|
||||
|
||||
// TargetPath 要覆盖的可执行文件路径,默认当前运行的可执行文件路径(会尝试解析 symlink)
|
||||
TargetPath string
|
||||
|
||||
// Backup 覆盖前是否备份旧文件(.bak)
|
||||
Backup bool
|
||||
|
||||
// ServiceName 需要控制的服务名(为空则不做服务控制)
|
||||
ServiceName string
|
||||
|
||||
// RestartService 是否在替换成功后重启服务(会导致服务短暂中断)
|
||||
RestartService bool
|
||||
|
||||
// WorkDir 升级临时目录(plan/lock/download cache 等)
|
||||
WorkDir string
|
||||
|
||||
// ServiceArgs 透传给 utils.ControlSystemService(参考 cmd/frpp/shared/cmd.go)
|
||||
ServiceArgs []string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func resolveTargetPath(targetPath string) (string, error) {
|
||||
if len(targetPath) == 0 {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取当前执行文件失败: %w", err)
|
||||
}
|
||||
// 优先解析符号链接,确保替换真实文件
|
||||
if realPath, err := filepath.EvalSymlinks(exePath); err == nil && len(realPath) > 0 {
|
||||
exePath = realPath
|
||||
}
|
||||
targetPath = exePath
|
||||
}
|
||||
if abs, err := filepath.Abs(targetPath); err == nil {
|
||||
targetPath = abs
|
||||
}
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
func stagePathForTarget(targetPath string) string {
|
||||
return targetPath + ".new"
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type plan struct {
|
||||
PlanVersion int `json:"plan_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RequestPID int `json:"request_pid"`
|
||||
|
||||
Options Options `json:"options"`
|
||||
}
|
||||
|
||||
func planPath(workDir string) string {
|
||||
if len(workDir) == 0 {
|
||||
workDir = defaultWorkDir()
|
||||
}
|
||||
return filepath.Join(workDir, "plan.json")
|
||||
}
|
||||
|
||||
func statusPath(workDir string) string {
|
||||
if len(workDir) == 0 {
|
||||
workDir = defaultWorkDir()
|
||||
}
|
||||
return filepath.Join(workDir, "status.json")
|
||||
}
|
||||
|
||||
type status struct {
|
||||
PlanVersion int `json:"plan_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writePlan(workDir string, opt Options) (string, error) {
|
||||
if len(workDir) == 0 {
|
||||
workDir = defaultWorkDir()
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("create work dir failed: %w", err)
|
||||
}
|
||||
|
||||
p := plan{
|
||||
PlanVersion: 1,
|
||||
CreatedAt: time.Now(),
|
||||
RequestPID: os.Getpid(),
|
||||
Options: opt,
|
||||
}
|
||||
b, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal plan failed: %w", err)
|
||||
}
|
||||
|
||||
pPath := planPath(workDir)
|
||||
// overwrite (single plan at a time)
|
||||
if err := os.WriteFile(pPath, b, 0600); err != nil {
|
||||
return "", fmt.Errorf("write plan failed: %w", err)
|
||||
}
|
||||
return pPath, nil
|
||||
}
|
||||
|
||||
func readPlan(planPath string) (plan, error) {
|
||||
var p plan
|
||||
b, err := os.ReadFile(planPath)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("read plan failed: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return p, fmt.Errorf("unmarshal plan failed: %w", err)
|
||||
}
|
||||
if p.PlanVersion != 1 {
|
||||
return p, fmt.Errorf("unsupported plan version: %d", p.PlanVersion)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func writeStatus(workDir string, success bool, msg string) error {
|
||||
if len(workDir) == 0 {
|
||||
workDir = defaultWorkDir()
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||
return fmt.Errorf("create work dir failed: %w", err)
|
||||
}
|
||||
s := status{
|
||||
PlanVersion: 1,
|
||||
UpdatedAt: time.Now(),
|
||||
Success: success,
|
||||
Message: msg,
|
||||
}
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal status failed: %w", err)
|
||||
}
|
||||
return os.WriteFile(statusPath(workDir), b, 0644)
|
||||
}
|
||||
|
||||
// Status 对外暴露升级结果(给 `upgrade status` 使用)
|
||||
type Status struct {
|
||||
PlanVersion int `json:"plan_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func ReadStatus(workDir string) (*Status, string, error) {
|
||||
if len(workDir) == 0 {
|
||||
workDir = defaultWorkDir()
|
||||
}
|
||||
p := statusPath(workDir)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, p, err
|
||||
}
|
||||
var s Status
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return nil, p, err
|
||||
}
|
||||
return &s, p, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func copyFile(src, dst string, perm os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func backupExisting(targetPath string) (string, error) {
|
||||
if _, err := os.Stat(targetPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("stat existing binary failed: %w", err)
|
||||
}
|
||||
backupPath := targetPath + ".bak"
|
||||
_ = os.Remove(backupPath)
|
||||
if err := copyFile(targetPath, backupPath, 0755); err != nil {
|
||||
return "", fmt.Errorf("backup existing binary failed: %w", err)
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func replaceFile(src, dst string) error {
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
// rename 失败则退化为 copy(跨文件系统等)
|
||||
if err := copyFile(src, dst, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build !windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func unameMachine() string {
|
||||
var u unix.Utsname
|
||||
if err := unix.Uname(&u); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(bytesToString(u.Machine[:]))
|
||||
}
|
||||
|
||||
func bytesToString(ca []byte) string {
|
||||
b := make([]byte, 0, len(ca))
|
||||
for _, c := range ca {
|
||||
if c == 0 {
|
||||
break
|
||||
}
|
||||
b = append(b, c)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/VaalaCat/frp-panel/utils"
|
||||
"github.com/VaalaCat/frp-panel/utils/logger"
|
||||
"github.com/kardianos/service"
|
||||
)
|
||||
|
||||
const upgraderServiceName = "frpp-upgrader"
|
||||
|
||||
type StartResult struct {
|
||||
Dispatched bool
|
||||
PlanPath string
|
||||
UpgraderService string
|
||||
}
|
||||
|
||||
// Start 执行升级(非 Windows:直接替换不影响当前进程;Windows:启动 worker 完成替换/服务控制)
|
||||
func Start(ctx context.Context, opt Options) error {
|
||||
_, err := StartWithResult(ctx, opt)
|
||||
return err
|
||||
}
|
||||
|
||||
func StartWithResult(ctx context.Context, opt Options) (StartResult, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// 关键诊断信息:帮助确认“到底执行的是哪个 frp-panel 二进制”
|
||||
exePath, _ := os.Executable()
|
||||
realExe := exePath
|
||||
if rp, err := filepath.EvalSymlinks(exePath); err == nil && len(rp) > 0 {
|
||||
realExe = rp
|
||||
}
|
||||
if abs, err := filepath.Abs(realExe); err == nil {
|
||||
realExe = abs
|
||||
}
|
||||
|
||||
target, err := resolveTargetPath(opt.TargetPath)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
opt.TargetPath = target
|
||||
if len(strings.TrimSpace(opt.Version)) == 0 {
|
||||
opt.Version = "latest"
|
||||
}
|
||||
if len(opt.WorkDir) == 0 {
|
||||
opt.WorkDir = defaultWorkDir()
|
||||
}
|
||||
|
||||
unlock, err := lock(opt.WorkDir)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
if err := utils.EnsureDirectoryExists(opt.TargetPath); err != nil {
|
||||
return StartResult{}, fmt.Errorf("ensure target directory failed: %w", err)
|
||||
}
|
||||
|
||||
downloadURL, err := buildDownloadURL(opt)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
|
||||
logger.Logger(ctx).Infof("upgrade: executable=%s, target=%s, restart_service=%v, service_name=%s",
|
||||
realExe, opt.TargetPath, opt.RestartService, strings.TrimSpace(opt.ServiceName))
|
||||
logger.Logger(ctx).Infof("upgrade: downloading version [%s], url: %s", opt.Version, downloadURL)
|
||||
tmpPath, err := utils.DownloadFile(ctx, downloadURL, strings.TrimSpace(opt.HTTPProxy))
|
||||
if err != nil {
|
||||
return StartResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// stage 到目标目录附近,避免跨文件系统 rename 问题
|
||||
staged := stagePathForTarget(opt.TargetPath)
|
||||
_ = os.Remove(staged)
|
||||
if err := copyFile(tmpPath, staged, 0755); err != nil {
|
||||
return StartResult{}, fmt.Errorf("stage new binary failed: %w", err)
|
||||
}
|
||||
_ = os.Chmod(staged, 0755)
|
||||
|
||||
if err := verifyBinary(staged); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
|
||||
// Linux 场景:避免“远程升级递归依赖”(stop/restart frpp 会杀掉同 unit/cgroup 下的升级进程)
|
||||
// 做法:写入固定 plan.json,然后启动独立的 upgrader service 去 stop→替换→start。
|
||||
if runtime.GOOS == "linux" && opt.RestartService && len(strings.TrimSpace(opt.ServiceName)) > 0 {
|
||||
planPath, err := writePlan(opt.WorkDir, opt)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
logger.Logger(ctx).Infof("upgrade: plan created at %s, dispatching upgrader service: %s", planPath, upgraderServiceName)
|
||||
|
||||
if err := ensureUpgraderService(ctx, planPath); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
// 异步:此处返回后 remoteshell 可以立刻得到响应;真正 stop/restart 将由 upgrader 完成
|
||||
return StartResult{Dispatched: true, PlanPath: planPath, UpgraderService: upgraderServiceName}, nil
|
||||
}
|
||||
|
||||
// Windows:无法覆盖正在运行的 exe,因此用独立 worker 来做服务 stop->replace->start
|
||||
if runtime.GOOS == "windows" {
|
||||
planPath, err := writePlan(opt.WorkDir, opt)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
// 让 worker 复用已 stage 的文件:约定 staged 固定为 targetPath+".new"
|
||||
if err := spawnWorker(mustExecutablePath(), planPath); err != nil {
|
||||
return StartResult{}, fmt.Errorf("start upgrade worker failed: %w", err)
|
||||
}
|
||||
logger.Logger(ctx).Info("upgrade worker started (windows). it will stop/replace/start service in background if configured")
|
||||
return StartResult{Dispatched: true, PlanPath: planPath, UpgraderService: ""}, nil
|
||||
}
|
||||
|
||||
// 非 Windows:当前进程可以继续运行,替换不会影响当前运行实例
|
||||
var backupPath string
|
||||
if opt.Backup {
|
||||
backupPath, err = backupExisting(opt.TargetPath)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := replaceFile(staged, opt.TargetPath); err != nil {
|
||||
// 回滚
|
||||
if len(backupPath) > 0 {
|
||||
_ = replaceFile(backupPath, opt.TargetPath)
|
||||
}
|
||||
return StartResult{}, fmt.Errorf("replace executable failed: %w", err)
|
||||
}
|
||||
|
||||
logger.Logger(ctx).Infof("upgrade: binary replaced successfully: %s", opt.TargetPath)
|
||||
|
||||
if opt.RestartService && len(strings.TrimSpace(opt.ServiceName)) > 0 {
|
||||
logger.Logger(ctx).Infof("upgrade: restarting service: %s", opt.ServiceName)
|
||||
// 参考 cmd/frpp/shared/cmd.go:使用 utils.ControlSystemService
|
||||
if err := utils.ControlSystemService(opt.ServiceName, opt.ServiceArgs, "restart", func() {}); err != nil {
|
||||
// 二进制已经替换成功,这里的重启失败不应导致整体 upgrade 失败(尤其是非 root 场景)
|
||||
logger.Logger(ctx).WithError(err).Warnf("restart service failed, please restart manually or run with sudo: %s", opt.ServiceName)
|
||||
return StartResult{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return StartResult{Dispatched: false, PlanPath: "", UpgraderService: ""}, nil
|
||||
}
|
||||
|
||||
func ensureUpgraderService(ctx context.Context, planPath string) error {
|
||||
// 统一使用 kardianos/service:兼容非 systemd 的 Linux(upstart/sysv/openrc)
|
||||
// 对 systemd:通过 SystemdScript/Restart 选项,避免 Restart=always 导致无限重启,
|
||||
// 并用 ConditionPathExists 防止 enable 后开机自启误触发升级。
|
||||
|
||||
args := []string{"__upgrade-worker", "--plan", planPath}
|
||||
|
||||
opts := service.KeyValue{
|
||||
// 默认 systemd 脚本会 Restart=always 且 install 会 enable,这会导致无限重启。
|
||||
// 我们定制为 oneshot + Restart=no + ConditionPathExists(plan)。
|
||||
"SystemdScript": systemdUpgraderScript(planPath),
|
||||
"Restart": "no",
|
||||
}
|
||||
|
||||
// 修复/覆盖旧 unit:stop + uninstall(忽略错误)后 install + start
|
||||
_ = utils.ControlSystemServiceWithOptions(upgraderServiceName, args, "stop", func() {}, opts)
|
||||
_ = utils.ControlSystemServiceWithOptions(upgraderServiceName, args, "uninstall", func() {}, opts)
|
||||
if err := utils.ControlSystemServiceWithOptions(upgraderServiceName, args, "install", func() {}, opts); err != nil {
|
||||
// 如果 install 因为已存在等原因失败,再尝试直接 start
|
||||
logger.Logger(ctx).WithError(err).Warn("upgrade: upgrader install failed, try start directly")
|
||||
}
|
||||
return utils.ControlSystemServiceWithOptions(upgraderServiceName, args, "start", func() {}, opts)
|
||||
}
|
||||
|
||||
func systemdUpgraderScript(planPath string) string {
|
||||
// 注意:这是 kardianos/service 的 systemd 模板文本,会被当作 text/template 解析,
|
||||
// 因此我们保留 {{.Path}} / {{.Arguments}} 等占位符,只把 planPath 写死进 ConditionPathExists。
|
||||
return fmt.Sprintf(`[Unit]
|
||||
Description=frp-panel upgrader (oneshot)
|
||||
ConditionFileIsExecutable={{.Path|cmdEscape}}
|
||||
ConditionPathExists=%s
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||
Restart=no
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, planPath)
|
||||
}
|
||||
|
||||
func mustExecutablePath() string {
|
||||
p, _ := os.Executable()
|
||||
if real, err := filepath.EvalSymlinks(p); err == nil && len(real) > 0 {
|
||||
p = real
|
||||
}
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// RunWorker 执行升级计划(给隐藏命令 __upgrade-worker 调用)
|
||||
func RunWorker(ctx context.Context, planPath string) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// upgrader service 可能在 boot 或无 plan 时被启动:此时直接退出,保证不会误停 frpp
|
||||
if _, err := os.Stat(planPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := readPlan(planPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opt := p.Options
|
||||
|
||||
target, err := resolveTargetPath(opt.TargetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opt.TargetPath = target
|
||||
|
||||
// worker 假设 staged 固定为 targetPath+".new"
|
||||
staged := stagePathForTarget(opt.TargetPath)
|
||||
if err := verifyBinary(staged); err != nil {
|
||||
_ = writeStatus(opt.WorkDir, false, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if opt.RestartService && len(strings.TrimSpace(opt.ServiceName)) > 0 {
|
||||
// stop(这一步会导致 remoteshell 断开,但 worker 在独立 service 里执行,不会被一起杀掉)
|
||||
if err := utils.ControlSystemService(opt.ServiceName, opt.ServiceArgs, "stop", func() {}); err != nil {
|
||||
_ = writeStatus(opt.WorkDir, false, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// replace:必须避免打开/truncate target(会触发 ETXTBSY),优先用 rename(staged 与 target 同目录)
|
||||
if err := replaceStagedToTarget(ctx, staged, opt.TargetPath, opt.Backup); err != nil {
|
||||
_ = writeStatus(opt.WorkDir, false, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if opt.RestartService && len(strings.TrimSpace(opt.ServiceName)) > 0 {
|
||||
if err := utils.ControlSystemService(opt.ServiceName, opt.ServiceArgs, "start", func() {}); err != nil {
|
||||
_ = writeStatus(opt.WorkDir, false, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.Remove(planPath) // 完成后清理 plan,避免开机自启误触发
|
||||
_ = writeStatus(opt.WorkDir, true, "ok")
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceStagedToTarget(ctx context.Context, staged, target string, backup bool) error {
|
||||
_ = ctx
|
||||
if len(staged) == 0 || len(target) == 0 {
|
||||
return fmt.Errorf("staged/target is empty")
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows:需要在 service stop 后才能动目标文件,且 rename 覆盖通常不允许
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
var backupPath string
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting target file unlock: %s", target)
|
||||
}
|
||||
|
||||
if backup && len(backupPath) == 0 {
|
||||
backupPath = target + ".bak"
|
||||
_ = os.Remove(backupPath)
|
||||
_ = os.Rename(target, backupPath)
|
||||
} else if !backup {
|
||||
_ = os.Remove(target)
|
||||
}
|
||||
|
||||
if err := os.Rename(staged, target); err != nil {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Unix-like:rename 覆盖是原子操作,且不受正在运行的旧 binary 影响(不会触发 ETXTBSY)
|
||||
if backup {
|
||||
backupPath := target + ".bak"
|
||||
_ = os.Remove(backupPath)
|
||||
// 备份采用 rename,避免 open/truncate
|
||||
_ = os.Rename(target, backupPath)
|
||||
}
|
||||
if err := os.Rename(staged, target); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func verifyFileNonEmpty(path string) error {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat failed: %w", err)
|
||||
}
|
||||
if st.Size() <= 0 {
|
||||
return fmt.Errorf("file is empty: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build !windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"debug/elf"
|
||||
"debug/macho"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func verifyBinary(path string) error {
|
||||
if err := verifyFileNonEmpty(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
f, err := elf.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ELF: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
// 简单架构匹配(尽量不误伤)
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
if f.FileHeader.Machine != elf.EM_X86_64 {
|
||||
return fmt.Errorf("arch mismatch: want amd64, got %v", f.FileHeader.Machine)
|
||||
}
|
||||
case "arm64":
|
||||
if f.FileHeader.Machine != elf.EM_AARCH64 {
|
||||
return fmt.Errorf("arch mismatch: want arm64, got %v", f.FileHeader.Machine)
|
||||
}
|
||||
case "arm":
|
||||
if f.FileHeader.Machine != elf.EM_ARM {
|
||||
return fmt.Errorf("arch mismatch: want arm, got %v", f.FileHeader.Machine)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "darwin":
|
||||
f, err := macho.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Mach-O: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
if f.Cpu != macho.CpuAmd64 {
|
||||
return fmt.Errorf("arch mismatch: want amd64, got %v", f.Cpu)
|
||||
}
|
||||
case "arm64":
|
||||
if f.Cpu != macho.CpuArm64 {
|
||||
return fmt.Errorf("arch mismatch: want arm64, got %v", f.Cpu)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
// 其他 Unix-like:仅做非空校验
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build windows
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"debug/pe"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func verifyBinary(path string) error {
|
||||
if err := verifyFileNonEmpty(path); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := pe.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid PE: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
if f.Machine != pe.IMAGE_FILE_MACHINE_AMD64 {
|
||||
return fmt.Errorf("arch mismatch: want amd64, got 0x%x", f.Machine)
|
||||
}
|
||||
case "arm64":
|
||||
if f.Machine != pe.IMAGE_FILE_MACHINE_ARM64 {
|
||||
return fmt.Errorf("arch mismatch: want arm64, got 0x%x", f.Machine)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func defaultWorkDir() string {
|
||||
// 对于 systemd 场景,优先使用持久目录,方便 upgrader service 读取 plan/status
|
||||
if runtime.GOOS == "linux" && os.Geteuid() == 0 {
|
||||
return filepath.Join(string(os.PathSeparator), "etc", "frpp", "upgrade")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "vaala-frp-panel-upgrade")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// spawnWorker 使用 os.StartProcess 拉起独立 worker(不使用 exec 包)
|
||||
func spawnWorker(exePath string, planPath string) error {
|
||||
if len(exePath) == 0 || len(planPath) == 0 {
|
||||
return fmt.Errorf("exePath/planPath is empty")
|
||||
}
|
||||
|
||||
argv := []string{exePath, "__upgrade-worker", "--plan", planPath}
|
||||
devnull, _ := os.OpenFile(os.DevNull, os.O_RDWR, 0)
|
||||
files := []*os.File{os.Stdin, os.Stdout, os.Stderr}
|
||||
if devnull != nil {
|
||||
files = []*os.File{devnull, devnull, devnull}
|
||||
}
|
||||
attr := &os.ProcAttr{
|
||||
Dir: filepath.Dir(exePath),
|
||||
Env: os.Environ(),
|
||||
Files: files,
|
||||
}
|
||||
applyDetachAttr(attr)
|
||||
|
||||
p, err := os.StartProcess(exePath, argv, attr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = p.Release()
|
||||
if devnull != nil {
|
||||
_ = devnull.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+73
-10
@@ -6,8 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
bizcommon "github.com/VaalaCat/frp-panel/biz/common"
|
||||
"github.com/VaalaCat/frp-panel/biz/common/upgrade"
|
||||
"github.com/VaalaCat/frp-panel/conf"
|
||||
"github.com/VaalaCat/frp-panel/defs"
|
||||
"github.com/VaalaCat/frp-panel/pb"
|
||||
@@ -56,6 +57,7 @@ func BuildCommand(fs embed.FS) *cobra.Command {
|
||||
NewStopServiceCmd(),
|
||||
NewRestartServiceCmd(),
|
||||
NewUpgradeCmd(cfg),
|
||||
NewUpgradeWorkerCmd(),
|
||||
NewVersionCmd(),
|
||||
)
|
||||
}
|
||||
@@ -346,18 +348,20 @@ func NewRestartServiceCmd() *cobra.Command {
|
||||
func NewUpgradeCmd(cfg conf.Config) *cobra.Command {
|
||||
upgradeCmd := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "auto upgrade frp-panel binary",
|
||||
Short: "OTA upgrade frp-panel binary (no service interruption unless restart)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
downloadURL, _ := cmd.Flags().GetString("download-url")
|
||||
githubProxy, _ := cmd.Flags().GetString("github-proxy")
|
||||
httpProxy, _ := cmd.Flags().GetString("http-proxy")
|
||||
binPath, _ := cmd.Flags().GetString("bin")
|
||||
noBackup, _ := cmd.Flags().GetBool("no-backup")
|
||||
skipServiceStop, _ := cmd.Flags().GetBool("no-service-stop")
|
||||
serviceName, _ := cmd.Flags().GetString("service-name")
|
||||
useGithubProxy, _ := cmd.Flags().GetBool("use-github-proxy")
|
||||
workDir, _ := cmd.Flags().GetString("workdir")
|
||||
restartService, _ := cmd.Flags().GetBool("restart-service")
|
||||
|
||||
if useGithubProxy && len(githubProxy) == 0 {
|
||||
githubProxy = cfg.App.GithubProxyUrl
|
||||
@@ -366,39 +370,98 @@ func NewUpgradeCmd(cfg conf.Config) *cobra.Command {
|
||||
httpProxy = cfg.HTTP_PROXY
|
||||
}
|
||||
|
||||
opts := bizcommon.UpgradeOptions{
|
||||
opts := upgrade.Options{
|
||||
Version: version,
|
||||
DownloadURL: downloadURL,
|
||||
GithubProxy: githubProxy,
|
||||
UseGithubProxy: useGithubProxy,
|
||||
HTTPProxy: httpProxy,
|
||||
TargetPath: binPath,
|
||||
Backup: !noBackup,
|
||||
StopService: !skipServiceStop,
|
||||
ServiceName: serviceName,
|
||||
UseGithubProxy: useGithubProxy,
|
||||
RestartService: restartService,
|
||||
WorkDir: workDir,
|
||||
ServiceArgs: args,
|
||||
}
|
||||
|
||||
if err := bizcommon.UpgradeSelf(ctx, opts); err != nil {
|
||||
res, err := upgrade.StartWithResult(ctx, opts)
|
||||
if err != nil {
|
||||
logger.Logger(ctx).Errorf("upgrade failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Logger(ctx).Info("upgrade completed, if you are using systemd service, please remember to restart frpp service to take effect")
|
||||
if res.Dispatched {
|
||||
logger.Logger(ctx).Infof("upgrade dispatched to background worker, connection may drop; plan: %s", res.PlanPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if restartService {
|
||||
logger.Logger(ctx).Info("upgrade completed (service restarted if applicable)")
|
||||
return nil
|
||||
}
|
||||
logger.Logger(ctx).Info("upgrade completed. to take effect, restart service/process when convenient")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
upgradeCmd.Flags().StringP("version", "v", "latest", "target version, default latest")
|
||||
upgradeCmd.Flags().Bool("use-github-proxy", false, "use github proxy when downloading release asset")
|
||||
upgradeCmd.Flags().String("download-url", "", "custom download url (highest priority), if set will ignore github-proxy")
|
||||
upgradeCmd.Flags().Bool("use-github-proxy", true, "use github proxy when downloading release asset")
|
||||
upgradeCmd.Flags().String("github-proxy", "", "github proxy prefix, e.g. https://ghfast.top/")
|
||||
upgradeCmd.Flags().String("http-proxy", "", "http/https proxy for download, default HTTP_PROXY")
|
||||
upgradeCmd.Flags().String("bin", "", "binary path to overwrite, default current running binary")
|
||||
upgradeCmd.Flags().Bool("no-backup", false, "do not create .bak backup before overwrite")
|
||||
upgradeCmd.Flags().Bool("no-service-stop", false, "do not stop systemd service before upgrade")
|
||||
upgradeCmd.Flags().String("service-name", "frpp", "systemd service name to control")
|
||||
upgradeCmd.Flags().String("workdir", "", "upgrade worker plan/lock directory (default system temp)")
|
||||
upgradeCmd.Flags().Bool("restart-service", true, "restart service after replace (will interrupt service)")
|
||||
|
||||
upgradeCmd.AddCommand(NewUpgradeStatusCmd())
|
||||
|
||||
return upgradeCmd
|
||||
}
|
||||
|
||||
func NewUpgradeStatusCmd() *cobra.Command {
|
||||
statusCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "show last upgrade status (from workdir/status.json)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
workDir, _ := cmd.Flags().GetString("workdir")
|
||||
st, p, err := upgrade.ReadStatus(workDir)
|
||||
if err != nil {
|
||||
logger.Logger(ctx).Errorf("read upgrade status failed: %v", err)
|
||||
return err
|
||||
}
|
||||
logger.Logger(ctx).Infof("status file: %s", p)
|
||||
logger.Logger(ctx).Infof("success=%v updated_at=%s message=%s", st.Success, st.UpdatedAt.Format(time.RFC3339), st.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
statusCmd.Flags().String("workdir", "", "upgrade worker plan/lock directory (default system temp)")
|
||||
return statusCmd
|
||||
}
|
||||
|
||||
func NewUpgradeWorkerCmd() *cobra.Command {
|
||||
workerCmd := &cobra.Command{
|
||||
Use: "__upgrade-worker",
|
||||
Short: "internal upgrade worker (do not call manually)",
|
||||
Hidden: true,
|
||||
DisableFlagParsing: false,
|
||||
DisableFlagsInUseLine: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
planPath, _ := cmd.Flags().GetString("plan")
|
||||
if len(planPath) == 0 {
|
||||
return errors.New("missing --plan")
|
||||
}
|
||||
return upgrade.RunWorker(ctx, planPath)
|
||||
},
|
||||
}
|
||||
workerCmd.Flags().String("plan", "", "upgrade plan file path")
|
||||
_ = workerCmd.Flags().MarkHidden("plan")
|
||||
return workerCmd
|
||||
}
|
||||
|
||||
func NewVersionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
github.com/coocood/freecache v1.2.4
|
||||
github.com/coreos/go-iptables v0.8.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/failsafe-go/failsafe-go v0.9.4
|
||||
github.com/fatedier/frp v0.65.0
|
||||
github.com/fatedier/golib v0.5.1
|
||||
github.com/gin-contrib/static v0.0.1
|
||||
@@ -27,9 +28,11 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/lucasepe/codename v0.2.0
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/pion/stun/v3 v3.0.2
|
||||
github.com/prometheus-community/pro-bing v0.7.0
|
||||
github.com/samber/lo v1.47.0
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.4
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sourcegraph/conc v0.3.0
|
||||
@@ -44,6 +47,7 @@ require (
|
||||
go.uber.org/multierr v1.11.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/net v0.44.0
|
||||
golang.org/x/sys v0.36.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
google.golang.org/grpc v1.67.1
|
||||
@@ -61,6 +65,7 @@ require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/casbin/govaluate v1.3.0 // indirect
|
||||
@@ -103,8 +108,8 @@ require (
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.6.0 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 // indirect
|
||||
@@ -126,6 +131,7 @@ require (
|
||||
github.com/quic-go/quic-go v0.53.0 // indirect
|
||||
github.com/refraction-networking/utls v1.7.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
@@ -148,7 +154,7 @@ require (
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
|
||||
@@ -31,6 +31,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
@@ -46,6 +48,8 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
@@ -75,6 +79,8 @@ github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/failsafe-go/failsafe-go v0.9.4 h1:dSIZYxXvRqh+PndhTb6LMeMXPz8iC21LfmwxSofMHSw=
|
||||
github.com/failsafe-go/failsafe-go v0.9.4/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54=
|
||||
github.com/fatedier/frp v0.65.0 h1:Wn8qjXXf+G7+czZB0o7Y4U7Ohy9q/LOAqzK5AVKEel4=
|
||||
github.com/fatedier/frp v0.65.0/go.mod h1:P5aPIs0cqLWDKIYkOziZQGZpb4DwzQkRyK5h+OtPp0Y=
|
||||
github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=
|
||||
@@ -179,6 +185,8 @@ github.com/imroc/req/v3 v3.55.0 h1:vg2Q33TGU12wZWZyPkiPbCGGTeiOmlEOdOwHLH03//I=
|
||||
github.com/imroc/req/v3 v3.55.0/go.mod h1:MOn++r2lE4+du3nuefTaPGQ6pY3/yRP2r1pFK1BUqq0=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY=
|
||||
github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
@@ -233,8 +241,12 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc=
|
||||
github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -291,6 +303,8 @@ github.com/refraction-networking/utls v1.7.3 h1:L0WRhHY7Oq1T0zkdzVZMR6zWZv+sXbHB
|
||||
github.com/refraction-networking/utls v1.7.3/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
@@ -298,6 +312,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
|
||||
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
|
||||
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
|
||||
+142
-9
@@ -6,12 +6,20 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/VaalaCat/frp-panel/utils/logger"
|
||||
"github.com/failsafe-go/failsafe-go"
|
||||
"github.com/failsafe-go/failsafe-go/retrypolicy"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
"go.uber.org/multierr"
|
||||
)
|
||||
|
||||
@@ -105,21 +113,146 @@ func DownloadFile(ctx context.Context, url string, proxyUrl string) (string, err
|
||||
cli = cli.SetProxyURL(proxyUrl)
|
||||
}
|
||||
|
||||
err = cli.NewParallelDownload(url).
|
||||
SetConcurrency(5).
|
||||
SetSegmentSize(1024 * 1024 * 1).
|
||||
SetOutputFile(fileFullPath).
|
||||
SetFileMode(0777).
|
||||
SetTempRootDir(path.Join(TmpFileDir, "downloads_cache")).
|
||||
Do()
|
||||
if err != nil {
|
||||
logger.Logger(ctx).Infof("Downloading file from url: %s with proxy: %s", url, proxyUrl)
|
||||
|
||||
// 进度条:仅在交互式终端展示,避免污染 service 日志
|
||||
showProgress := isatty.IsTerminal(os.Stderr.Fd())
|
||||
|
||||
// failsafe-go Retry 策略(参考:https://failsafe-go.dev)
|
||||
retryPolicy := retrypolicy.NewBuilder[any]().
|
||||
HandleIf(func(_ any, err error) bool { return isRetryableDownloadErr(err) }).
|
||||
WithMaxRetries(2). // 总共 3 次尝试
|
||||
WithBackoff(500*time.Millisecond, 2*time.Second).
|
||||
Build()
|
||||
|
||||
runAttempt := func() error {
|
||||
_ = os.Remove(fileFullPath)
|
||||
|
||||
if showProgress {
|
||||
// 使用 req 的下载回调驱动进度条(文档:
|
||||
// https://req.cool/docs/tutorial/download/#use-download-callback)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
bar *progressbar.ProgressBar
|
||||
lastBytes int64
|
||||
)
|
||||
callback := func(info req.DownloadInfo) {
|
||||
if info.Response == nil || info.Response.Response == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if bar == nil {
|
||||
max := info.Response.ContentLength
|
||||
if max <= 0 {
|
||||
max = -1
|
||||
}
|
||||
bar = progressbar.NewOptions64(
|
||||
max,
|
||||
progressbar.OptionSetWriter(os.Stderr),
|
||||
progressbar.OptionShowBytes(true),
|
||||
progressbar.OptionSetWidth(24),
|
||||
progressbar.OptionSetDescription("Downloading..."),
|
||||
progressbar.OptionThrottle(200*time.Millisecond),
|
||||
progressbar.OptionClearOnFinish(),
|
||||
)
|
||||
}
|
||||
|
||||
delta := info.DownloadedSize - lastBytes
|
||||
if delta > 0 {
|
||||
_ = bar.Add64(delta)
|
||||
lastBytes = info.DownloadedSize
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := cli.R().
|
||||
SetContext(ctx).
|
||||
SetOutputFile(fileFullPath).
|
||||
SetDownloadCallbackWithInterval(callback, 200*time.Millisecond).
|
||||
SetRetryCount(0).
|
||||
Get(url)
|
||||
|
||||
if bar != nil {
|
||||
_ = bar.Finish()
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil && resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("server error: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 非交互终端:保持并行下载(更快,也避免刷日志)
|
||||
cacheDir := path.Join(tmpPath, "downloads_cache")
|
||||
_ = os.RemoveAll(cacheDir)
|
||||
err := cli.NewParallelDownload(url).
|
||||
SetConcurrency(5).
|
||||
SetSegmentSize(1024 * 1024 * 1).
|
||||
SetOutputFile(fileFullPath).
|
||||
SetFileMode(0777).
|
||||
SetTempRootDir(cacheDir).
|
||||
Do()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := failsafe.With(retryPolicy).Run(runAttempt); err != nil {
|
||||
logger.Logger(ctx).WithError(err).Error("download file from url error")
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fileFullPath, nil
|
||||
}
|
||||
|
||||
func isRetryableDownloadErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// 网络抖动类:EOF/意外断开
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
return true
|
||||
}
|
||||
// 常见网络错误(net.Error)
|
||||
if ne, ok := err.(net.Error); ok {
|
||||
if ne.Timeout() || ne.Temporary() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 不能用 errors.As(这里为了不引入更多依赖):
|
||||
// 退而求其次:字符串匹配(兼容 req / tls / http2 的 wrapped error)
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case msg == "unexpected EOF":
|
||||
return true
|
||||
case containsAny(msg,
|
||||
"connection reset by peer",
|
||||
"use of closed network connection",
|
||||
"TLS handshake timeout",
|
||||
"i/o timeout",
|
||||
"timeout",
|
||||
"temporary failure",
|
||||
"server error:",
|
||||
):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if len(sub) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateRandomFileName 生成一个随机文件名
|
||||
func generateRandomFileName(prefix, extension string) string {
|
||||
randomStr := randomString(8)
|
||||
|
||||
+10
-1
@@ -36,6 +36,10 @@ func (ss *SystemService) iRun() {
|
||||
}
|
||||
|
||||
func CreateSystemService(svcName string, args []string, run func()) (service.Service, error) {
|
||||
return CreateSystemServiceWithOptions(svcName, args, run, nil)
|
||||
}
|
||||
|
||||
func CreateSystemServiceWithOptions(svcName string, args []string, run func(), options service.KeyValue) (service.Service, error) {
|
||||
currentPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get current path failed, err: %v", err)
|
||||
@@ -47,6 +51,7 @@ func CreateSystemService(svcName string, args []string, run func()) (service.Ser
|
||||
Description: "this is frp-panel service, developed by [VaalaCat] - https://github.com/VaalaCat/frp-panel",
|
||||
Arguments: args,
|
||||
WorkingDirectory: path.Dir(currentPath),
|
||||
Option: options,
|
||||
}
|
||||
|
||||
ss := &SystemService{
|
||||
@@ -61,10 +66,14 @@ func CreateSystemService(svcName string, args []string, run func()) (service.Ser
|
||||
}
|
||||
|
||||
func ControlSystemService(svcName string, args []string, action string, run func()) error {
|
||||
return ControlSystemServiceWithOptions(svcName, args, action, run, nil)
|
||||
}
|
||||
|
||||
func ControlSystemServiceWithOptions(svcName string, args []string, action string, run func(), options service.KeyValue) error {
|
||||
ctx := context.Background()
|
||||
|
||||
logger.Logger(ctx).Info("try to ", action, " service, args:", args)
|
||||
s, err := CreateSystemService(svcName, args, run)
|
||||
s, err := CreateSystemServiceWithOptions(svcName, args, run, options)
|
||||
if err != nil {
|
||||
logger.Logger(ctx).WithError(err).Error("create service controller failed")
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user