feat: ota upgrade command

This commit is contained in:
VaalaCat
2026-01-02 12:23:06 +00:00
parent 10325bcb7e
commit e84e6b7732
24 changed files with 1197 additions and 293 deletions
-271
View File
@@ -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
}
+41
View File
@@ -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)
}
+21
View File
@@ -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)
}
}
+17
View File
@@ -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,
}
}
+18
View File
@@ -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,
}
}
+28
View File
@@ -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
}
+33
View File
@@ -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
}
+42
View File
@@ -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
}
+39
View File
@@ -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
}
+29
View File
@@ -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"
}
+124
View File
@@ -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
}
+52
View File
@@ -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
}
+30
View File
@@ -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)
}
+310
View File
@@ -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 的 Linuxupstart/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",
}
// 修复/覆盖旧 unitstop + 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),优先用 renamestaged 与 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-likerename 覆盖是原子操作,且不受正在运行的旧 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
}
+17
View File
@@ -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
}
+63
View File
@@ -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
}
}
+32
View File
@@ -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
}
+15
View File
@@ -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")
}
+37
View File
@@ -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
View File
@@ -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",
+8 -2
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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