From aefb72fb5e9cada63e600ba6dbb25a233af92ef7 Mon Sep 17 00:00:00 2001 From: VaalaCat Date: Tue, 9 Dec 2025 15:35:48 +0000 Subject: [PATCH] feat: self upgrade command --- biz/common/upgrade.go | 271 +++++++++++++++++++++++++++++++++++++++++ cmd/frpp/shared/cmd.go | 58 +++++++++ docs/en/wireguard.md | 5 +- docs/wireguard.md | 6 +- 4 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 biz/common/upgrade.go diff --git a/biz/common/upgrade.go b/biz/common/upgrade.go new file mode 100644 index 0000000..ed93e4a --- /dev/null +++ b/biz/common/upgrade.go @@ -0,0 +1,271 @@ +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/cmd/frpp/shared/cmd.go b/cmd/frpp/shared/cmd.go index 23eb60e..4af0228 100644 --- a/cmd/frpp/shared/cmd.go +++ b/cmd/frpp/shared/cmd.go @@ -7,6 +7,7 @@ import ( "fmt" "os" + bizcommon "github.com/VaalaCat/frp-panel/biz/common" "github.com/VaalaCat/frp-panel/conf" "github.com/VaalaCat/frp-panel/defs" "github.com/VaalaCat/frp-panel/pb" @@ -54,6 +55,7 @@ func BuildCommand(fs embed.FS) *cobra.Command { NewStartServiceCmd(), NewStopServiceCmd(), NewRestartServiceCmd(), + NewUpgradeCmd(cfg), NewVersionCmd(), ) } @@ -341,6 +343,62 @@ func NewRestartServiceCmd() *cobra.Command { } } +func NewUpgradeCmd(cfg conf.Config) *cobra.Command { + upgradeCmd := &cobra.Command{ + Use: "upgrade", + Short: "自助升级 frp-panel 二进制", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + version, _ := cmd.Flags().GetString("version") + 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") + + if useGithubProxy && len(githubProxy) == 0 { + githubProxy = cfg.App.GithubProxyUrl + } + if len(httpProxy) == 0 { + httpProxy = cfg.HTTP_PROXY + } + + opts := bizcommon.UpgradeOptions{ + Version: version, + GithubProxy: githubProxy, + HTTPProxy: httpProxy, + TargetPath: binPath, + Backup: !noBackup, + StopService: !skipServiceStop, + ServiceName: serviceName, + UseGithubProxy: useGithubProxy, + } + + if err := bizcommon.UpgradeSelf(ctx, opts); err != nil { + logger.Logger(ctx).Errorf("升级失败: %v", err) + return err + } + + logger.Logger(ctx).Info("升级完成,如为 systemd 服务请记得重启 frpp 服务生效") + 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("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") + + return upgradeCmd +} + func NewVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", diff --git a/docs/en/wireguard.md b/docs/en/wireguard.md index 64cad10..22e4682 100644 --- a/docs/en/wireguard.md +++ b/docs/en/wireguard.md @@ -57,7 +57,7 @@ If your Linux system has `/etc/sysctl.d`, run: ```bash echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf -echo 'net.ipv4.icmp_echo_ignore_all = 1' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf +echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf sudo sysctl -p /etc/sysctl.d/99-frp-panel.conf ``` @@ -66,7 +66,7 @@ Otherwise, use `/etc/sysctl.conf`: ```bash echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf -echo 'net.ipv4.icmp_echo_ignore_all = 1' | sudo tee -a /etc/sysctl.conf +echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee -a /etc/sysctl.conf sudo sysctl -p /etc/sysctl.conf ``` @@ -158,4 +158,3 @@ If you need two nodes to always connect directly, you have two options: 1. Configure ACLs so the two nodes can only communicate via direct connections. 2. Create a manual connection between them and set bandwidth to 1000 Mbps so they prefer a direct link. - diff --git a/docs/wireguard.md b/docs/wireguard.md index 11ff61d..92a9b45 100644 --- a/docs/wireguard.md +++ b/docs/wireguard.md @@ -56,14 +56,14 @@ frp-panel 目前内置了 wiregaurd-go 用于实现组网功能。并且实现 ```bash echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf -echo 'net.ipv4.icmp_echo_ignore_all = 1' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf +echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee -a /etc/sysctl.d/99-frp-panel.conf sudo sysctl -p /etc/sysctl.d/99-frp-panel.conf ``` 否则, 使用 `/etc/sysctl.conf` 文件: ```bash echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf -echo 'net.ipv4.icmp_echo_ignore_all = 1' | sudo tee -a /etc/sysctl.conf +echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee -a /etc/sysctl.conf sudo sysctl -p /etc/sysctl.conf ``` @@ -155,4 +155,4 @@ ACL 是 JSON 格式,`action` 可以是 `allow` 或 `deny`,`src` 和 `dst` 如果你想两个节点无论何时都直接连接,有两个方法: 1. 配置ACL,让两个节点之间只能通过直接连接通信; -2. 配置手动连接,为他们配置1000Mbps的带宽,让两个节点之间可以直接连接。 \ No newline at end of file +2. 配置手动连接,为他们配置1000Mbps的带宽,让两个节点之间可以直接连接。