feat: self upgrade command

This commit is contained in:
VaalaCat
2025-12-09 15:35:48 +00:00
parent 2ec8387439
commit aefb72fb5e
4 changed files with 334 additions and 6 deletions

271
biz/common/upgrade.go Normal file
View File

@@ -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
}

View File

@@ -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",

View File

@@ -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.

View File

@@ -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的带宽让两个节点之间可以直接连接。
2. 配置手动连接为他们配置1000Mbps的带宽让两个节点之间可以直接连接。