diff --git a/biz/common/upgrade.go b/biz/common/upgrade.go deleted file mode 100644 index ed93e4a..0000000 --- a/biz/common/upgrade.go +++ /dev/null @@ -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 -} diff --git a/biz/common/upgrade/asset_unix.go b/biz/common/upgrade/asset_unix.go new file mode 100644 index 0000000..9529c9e --- /dev/null +++ b/biz/common/upgrade/asset_unix.go @@ -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) +} + + diff --git a/biz/common/upgrade/asset_windows.go b/biz/common/upgrade/asset_windows.go new file mode 100644 index 0000000..62712b8 --- /dev/null +++ b/biz/common/upgrade/asset_windows.go @@ -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) + } +} + + diff --git a/biz/common/upgrade/detach_unix.go b/biz/common/upgrade/detach_unix.go new file mode 100644 index 0000000..3d64c01 --- /dev/null +++ b/biz/common/upgrade/detach_unix.go @@ -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, + } +} diff --git a/biz/common/upgrade/detach_windows.go b/biz/common/upgrade/detach_windows.go new file mode 100644 index 0000000..215be27 --- /dev/null +++ b/biz/common/upgrade/detach_windows.go @@ -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, + } +} diff --git a/biz/common/upgrade/download_url.go b/biz/common/upgrade/download_url.go new file mode 100644 index 0000000..809c51b --- /dev/null +++ b/biz/common/upgrade/download_url.go @@ -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 +} + + diff --git a/biz/common/upgrade/lock.go b/biz/common/upgrade/lock.go new file mode 100644 index 0000000..ef00c3b --- /dev/null +++ b/biz/common/upgrade/lock.go @@ -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 +} + + diff --git a/biz/common/upgrade/lock_unix.go b/biz/common/upgrade/lock_unix.go new file mode 100644 index 0000000..7d3a9c0 --- /dev/null +++ b/biz/common/upgrade/lock_unix.go @@ -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 +} + + diff --git a/biz/common/upgrade/options.go b/biz/common/upgrade/options.go new file mode 100644 index 0000000..4f4b565 --- /dev/null +++ b/biz/common/upgrade/options.go @@ -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 +} + + diff --git a/biz/common/upgrade/paths.go b/biz/common/upgrade/paths.go new file mode 100644 index 0000000..ce1a771 --- /dev/null +++ b/biz/common/upgrade/paths.go @@ -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" +} diff --git a/biz/common/upgrade/plan.go b/biz/common/upgrade/plan.go new file mode 100644 index 0000000..e5fd6d4 --- /dev/null +++ b/biz/common/upgrade/plan.go @@ -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 +} diff --git a/biz/common/upgrade/replace.go b/biz/common/upgrade/replace.go new file mode 100644 index 0000000..1cdea8f --- /dev/null +++ b/biz/common/upgrade/replace.go @@ -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 +} diff --git a/biz/common/upgrade/uname_unix.go b/biz/common/upgrade/uname_unix.go new file mode 100644 index 0000000..91debe8 --- /dev/null +++ b/biz/common/upgrade/uname_unix.go @@ -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) +} + + diff --git a/biz/common/upgrade/upgrade.go b/biz/common/upgrade/upgrade.go new file mode 100644 index 0000000..646de5c --- /dev/null +++ b/biz/common/upgrade/upgrade.go @@ -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 +} diff --git a/biz/common/upgrade/verify.go b/biz/common/upgrade/verify.go new file mode 100644 index 0000000..55ccf54 --- /dev/null +++ b/biz/common/upgrade/verify.go @@ -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 +} diff --git a/biz/common/upgrade/verify_unix.go b/biz/common/upgrade/verify_unix.go new file mode 100644 index 0000000..9773c12 --- /dev/null +++ b/biz/common/upgrade/verify_unix.go @@ -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 + } +} + + diff --git a/biz/common/upgrade/verify_windows.go b/biz/common/upgrade/verify_windows.go new file mode 100644 index 0000000..f785005 --- /dev/null +++ b/biz/common/upgrade/verify_windows.go @@ -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 +} diff --git a/biz/common/upgrade/workdir.go b/biz/common/upgrade/workdir.go new file mode 100644 index 0000000..b37a0bc --- /dev/null +++ b/biz/common/upgrade/workdir.go @@ -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") +} diff --git a/biz/common/upgrade/worker_spawn.go b/biz/common/upgrade/worker_spawn.go new file mode 100644 index 0000000..ab09376 --- /dev/null +++ b/biz/common/upgrade/worker_spawn.go @@ -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 +} diff --git a/cmd/frpp/shared/cmd.go b/cmd/frpp/shared/cmd.go index cd9fbd3..ce6342e 100644 --- a/cmd/frpp/shared/cmd.go +++ b/cmd/frpp/shared/cmd.go @@ -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", diff --git a/go.mod b/go.mod index fc011e0..024a68f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7960bbd..58e53a9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/utils/files.go b/utils/files.go index 8914061..77c3c4c 100644 --- a/utils/files.go +++ b/utils/files.go @@ -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) diff --git a/utils/system_service.go b/utils/system_service.go index bd87111..99343d9 100644 --- a/utils/system_service.go +++ b/utils/system_service.go @@ -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