From e27e4c1770fd2d06df995d1e4a4bcb55bd71d7ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Fri, 9 Oct 2026 15:06:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E5=B7=B2=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ace/wire_gen.go | 6 +++-- cmd/cli/wire_gen.go | 3 ++- internal/bootstrap/db.go | 12 +++++----- internal/data/backup.go | 18 ++++++++------- internal/middleware/middleware.go | 6 ++--- internal/service/firewall.go | 5 ++++- internal/service/toolbox_ssh.go | 31 ++++++++++++++++++++++---- pkg/firewall/firewall.go | 29 +++++++++++++++++++----- pkg/tools/tools.go | 13 ++++------- web/src/views/firewall/IndexView.vue | 16 +++++++++---- web/src/views/firewall/SettingView.vue | 2 +- 11 files changed, 97 insertions(+), 44 deletions(-) diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go index edd7af98c..6a28e9f61 100644 --- a/cmd/ace/wire_gen.go +++ b/cmd/ace/wire_gen.go @@ -71,12 +71,13 @@ func initAce() (*app.Ace, func() error, error) { wireValue18_0 := caddy.NewApp(wireValue1_0) - wireValue4_0, wireErr4 := bootstrap.NewDB(wireValue0_0) + wireValue4_0, wireResourceCleanup4, wireErr4 := bootstrap.NewDB(wireValue0_0) if wireErr4 != nil { wireCommitted = true cleanupErr := wireCleanup.close() return wireZero0, nil, errors.Join(wireErr4, cleanupErr) } + wireCleanup.arm(wireResourceCleanup4) wireValue106_0 := data.NewDatabaseServerRepo(wireValue4_0) @@ -177,12 +178,13 @@ func initAce() (*app.Ace, func() error, error) { wireValue123_0 := data.NewUserTokenRepo(wireValue0_0, wireValue4_0, wireValue1_0) - wireValue9_0, wireErr9 := middleware.NewMiddlewares(wireValue0_0, wireValue1_0, wireValue6_0, wireValue90_0, wireValue123_0) + wireValue9_0, wireResourceCleanup9, wireErr9 := middleware.NewMiddlewares(wireValue0_0, wireValue1_0, wireValue6_0, wireValue90_0, wireValue123_0) if wireErr9 != nil { wireCommitted = true cleanupErr := wireCleanup.close() return wireZero0, nil, errors.Join(wireErr9, cleanupErr) } + wireCleanup.arm(wireResourceCleanup9) wireValue8_0 := bootstrap.NewValidator(wireValue0_0, wireValue4_0, wireValue1_0) diff --git a/cmd/cli/wire_gen.go b/cmd/cli/wire_gen.go index dc4a081db..8333adac8 100644 --- a/cmd/cli/wire_gen.go +++ b/cmd/cli/wire_gen.go @@ -30,12 +30,13 @@ func initCli() (*app.Cli, func() error, error) { wireValue1_0 := bootstrap.NewT(wireValue0_0) - wireValue4_0, wireErr4 := bootstrap.NewDB(wireValue0_0) + wireValue4_0, wireResourceCleanup4, wireErr4 := bootstrap.NewDB(wireValue0_0) if wireErr4 != nil { wireCommitted = true cleanupErr := wireCleanup.close() return wireZero0, nil, errors.Join(wireErr4, cleanupErr) } + wireCleanup.arm(wireResourceCleanup4) wireValue2_0, wireResourceCleanup2, wireErr2 := bootstrap.NewLogger(wireValue0_0) if wireErr2 != nil { diff --git a/internal/bootstrap/db.go b/internal/bootstrap/db.go index 8f5a858d0..a366c3192 100644 --- a/internal/bootstrap/db.go +++ b/internal/bootstrap/db.go @@ -1,6 +1,7 @@ package bootstrap import ( + "errors" "log/slog" "path/filepath" "time" @@ -16,7 +17,8 @@ import ( "github.com/acepanel/panel/v3/pkg/config" ) -func NewDB(conf *config.Config) (*gorm.DB, error) { +// NewDB 打开面板数据库,返回的 cleanup 在进程退出时关闭连接 +func NewDB(conf *config.Config) (*gorm.DB, func() error, error) { // db 日志写入轮转文件 w, err := logrotate.New(filepath.Join(app.Root, "panel/storage/logs/db.log"), logrotate.WithMaxSize(10*logrotate.MB), @@ -26,7 +28,7 @@ func NewDB(conf *config.Config) (*gorm.DB, error) { logrotate.WithLocation(time.Local), ) if err != nil { - return nil, err + return nil, nil, err } handler := slog.New(slog.NewJSONHandler(w, nil)).Handler() @@ -42,17 +44,17 @@ func NewDB(conf *config.Config) (*gorm.DB, error) { DisableForeignKeyConstraintWhenMigrating: true, }) if err != nil { - return nil, err + return nil, nil, err } sqlDB, err := db.DB() if err != nil { - return nil, err + return nil, nil, err } sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxIdleConns(1) - return db, nil + return db, func() error { return errors.Join(sqlDB.Close(), w.Close()) }, nil } func NewMigrate(db *gorm.DB) *gormigrate.Gormigrate { diff --git a/internal/data/backup.go b/internal/data/backup.go index ad54fc33a..0bdbe8efd 100644 --- a/internal/data/backup.go +++ b/internal/data/backup.go @@ -1670,10 +1670,12 @@ func (r *backupRepo) FixPanel(ctx context.Context) error { return errors.New(r.t.Get("Files are normal and do not need to be repaired, please run acepanel update to update the panel")) } - // 有异常,先停止面板 + // 有异常,先同步停掉面板再动文件,避免运行中的面板往新目录写东西 // 停掉面板后的修复被取消会让面板起不来,后续全程不可取消 ctx = context.WithoutCancel(ctx) - tools.StopPanel(ctx) + if err := systemctl.Stop(ctx, "acepanel"); err != nil { + return errors.New(r.t.Get("Failed to stop panel: %v", err)) + } // 删除损坏的辅助数据库(会自动重建) for _, name := range brokenAuxDBs { @@ -1686,12 +1688,14 @@ func (r *backupRepo) FixPanel(ctx context.Context) error { } } - // 仅辅助数据库异常,重启即可恢复 + // 仅辅助数据库异常,重新启动即可恢复 if !panelBroken { + if err := systemctl.Start(ctx, "acepanel"); err != nil { + return errors.New(r.t.Get("Failed to start panel: %v", err)) + } if app.IsCli { fmt.Println(r.t.Get("|-Fix completed")) } - tools.RestartPanel(ctx) return nil } @@ -1727,6 +1731,7 @@ func (r *backupRepo) FixPanel(ctx context.Context) error { } // restorePanel 用指定的面板备份覆盖当前面板,完成后重启面板 +// 面板内恢复备份与 CLI 修复共用,重启必须走 RestartPanel 的延迟排程,面板内调用才不会自杀在半路 func (r *backupRepo) restorePanel(ctx context.Context, backup string) error { // 覆盖的是运行中的面板二进制与 systemd 单元,中途取消会留下起不来的面板 ctx = context.WithoutCancel(ctx) @@ -1898,10 +1903,7 @@ func (r *backupRepo) UpdatePanel(ctx context.Context, version, url, checksum str r.log.Info("panel updated", slog.String("version", version)) progress(r.t.Get("Update completed")) - // 由调用方重启面板 - if sqlDB, err := r.db.DB(); err == nil { - _ = sqlDB.Close() - } + // 由调用方重启面板,数据库连接在进程退出时统一关闭 return nil } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 59c586244..e46431711 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -28,7 +28,7 @@ type Middlewares struct { userToken biz.UserTokenRepo } -func NewMiddlewares(conf *config.Config, t *gotext.Locale, session *sessions.Manager, appRepo biz.AppRepo, userTokenRepo biz.UserTokenRepo) (*Middlewares, error) { +func NewMiddlewares(conf *config.Config, t *gotext.Locale, session *sessions.Manager, appRepo biz.AppRepo, userTokenRepo biz.UserTokenRepo) (*Middlewares, func() error, error) { // http 访问日志写入轮转文件 w, err := logrotate.New(filepath.Join(app.Root, "panel/storage/logs/http.log"), logrotate.WithMaxSize(10*logrotate.MB), @@ -38,7 +38,7 @@ func NewMiddlewares(conf *config.Config, t *gotext.Locale, session *sessions.Man logrotate.WithLocation(time.Local), ) if err != nil { - return nil, err + return nil, nil, err } return &Middlewares{ @@ -48,7 +48,7 @@ func NewMiddlewares(conf *config.Config, t *gotext.Locale, session *sessions.Man session: session, appRepo: appRepo, userToken: userTokenRepo, - }, nil + }, w.Close, nil } // Globals 全局中间件集合,应用到每个请求;whitelist 为登录白名单路径。 diff --git a/internal/service/firewall.go b/internal/service/firewall.go index 806abe37c..5a374ddb1 100644 --- a/internal/service/firewall.go +++ b/internal/service/firewall.go @@ -44,7 +44,10 @@ func (s *FirewallService) GetStatus(w http.ResponseWriter, r *http.Request) { return } - Success(w, running) + Success(w, chix.M{ + "running": running, + "name": s.firewall.Name(), + }) } func (s *FirewallService) UpdateStatus(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/toolbox_ssh.go b/internal/service/toolbox_ssh.go index 74acf4ea0..80ef2b4d9 100644 --- a/internal/service/toolbox_ssh.go +++ b/internal/service/toolbox_ssh.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cast" "github.com/acepanel/panel/v3/internal/request" + "github.com/acepanel/panel/v3/pkg/firewall" "github.com/acepanel/panel/v3/pkg/io" "github.com/acepanel/panel/v3/pkg/os" "github.com/acepanel/panel/v3/pkg/shell" @@ -17,8 +18,9 @@ import ( ) type ToolboxSSHService struct { - t *gotext.Locale - service string + t *gotext.Locale + service string + firewall firewall.Firewall } func NewToolboxSSHService(t *gotext.Locale) *ToolboxSSHService { @@ -28,8 +30,9 @@ func NewToolboxSSHService(t *gotext.Locale) *ToolboxSSHService { service = "ssh" } return &ToolboxSSHService{ - t: t, - service: service, + t: t, + service: service, + firewall: firewall.NewFirewall(), } } @@ -87,6 +90,26 @@ func (s *ToolboxSSHService) UpdatePort(w http.ResponseWriter, r *http.Request) { return } + // 先放行再改端口 + running, err := s.firewall.Status(r.Context()) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + if running { + if err = s.firewall.Port(r.Context(), firewall.FireInfo{ + Type: firewall.TypeNormal, + PortStart: req.Port, + PortEnd: req.Port, + Protocol: firewall.ProtocolTCP, + Strategy: firewall.StrategyAccept, + Direction: firewall.DirectionIn, + }, firewall.OperationAdd); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to update firewall rule: %v", err)) + return + } + } + if err = s.updateSSHConfig("Port", cast.ToString(req.Port)); err != nil { Error(w, http.StatusInternalServerError, s.t.Get("failed to update SSH port: %v", err)) return diff --git a/pkg/firewall/firewall.go b/pkg/firewall/firewall.go index 730bd47e0..30d179061 100644 --- a/pkg/firewall/firewall.go +++ b/pkg/firewall/firewall.go @@ -13,8 +13,15 @@ import ( ) // Firewall 防火墙统一接口 -// 变更方法由 NewFirewall 返回的实现统一加锁并断开取消链,各后端实现内无需再处理 type Firewall interface { + backend + // Name 当前使用的防火墙软件名,未检测到支持的防火墙时为空 + Name() string +} + +// backend 各防火墙软件需实现的操作 +// 变更方法由 NewFirewall 返回的实现统一加锁并断开取消链,各后端实现内无需再处理 +type backend interface { // Status 获取防火墙运行状态 Status(ctx context.Context) (bool, error) // Enable 启用防火墙 @@ -45,18 +52,24 @@ func NewFirewall() Firewall { return lockedFirewall{} } +// detected 探测到的防火墙后端及其软件名 +type detected struct { + backend + name string +} + // detectFirewall 防火墙类型是开机后不再变化的环境事实,探测一次后全局复用 // 探测用不可取消 ctx:跟随调用方 ctx 时,取消会让两次探测都失败而退回 firewalld 默认值,并把这个错误结论缓存到进程结束 -var detectFirewall = sync.OnceValue(func() Firewall { +var detectFirewall = sync.OnceValue(func() detected { ctx := context.Background() if _, err := shell.Execf(ctx, "firewall-cmd --version"); err == nil { - return newFirewalld() + return detected{newFirewalld(), "firewalld"} } if _, err := shell.Execf(ctx, "ufw version"); err == nil { - return newUFW() + return detected{newUFW(), "ufw"} } - // 默认 firewalld - return newFirewalld() + // 都没装时按 firewalld 处理,名字留空供上层提示未安装 + return detected{newFirewalld(), ""} }) // mu 串行化所有防火墙变更操作 @@ -68,6 +81,10 @@ var mu sync.Mutex // firewalld 下规则进了永久配置却没进 runtime,ufw 下面板读的规则文件已改而内核里的规则还在跑 type lockedFirewall struct{} +func (lockedFirewall) Name() string { + return detectFirewall().name +} + func (lockedFirewall) Status(ctx context.Context) (bool, error) { return detectFirewall().Status(ctx) } diff --git a/pkg/tools/tools.go b/pkg/tools/tools.go index 696302977..da0276e8d 100644 --- a/pkg/tools/tools.go +++ b/pkg/tools/tools.go @@ -186,22 +186,17 @@ func CollectTopProcesses() types.TopProcesses { return result } -// 以下三个自杀式操作靠 sleep 1 熬过 HTTP 响应,ExecfAsync 入口已断开取消链, -// 调用方直接传请求 ctx 即可,无需再包 WithoutCancel - -// StopPanel 停止面板 -func StopPanel(ctx context.Context) { - _ = shell.ExecfAsync(ctx, "sleep 1 && systemctl stop acepanel") -} +// 以下两个自杀式操作交给 systemd 的瞬态定时器延迟 1 秒执行,既熬过 HTTP 响应, +// 又脱离面板自身的 cgroup,不会在停服务时被连带杀掉而误报命令失败 // RestartPanel 重启面板 func RestartPanel(ctx context.Context) { - _ = shell.ExecfAsync(ctx, "sleep 1 && systemctl restart acepanel") + _ = shell.ExecfAsync(ctx, "systemd-run --quiet --collect --on-active=1 systemctl restart acepanel") } // RestartServer 重启服务器 func RestartServer(ctx context.Context) { - _ = shell.ExecfAsync(ctx, "sleep 1 && reboot") + _ = shell.ExecfAsync(ctx, "systemd-run --quiet --collect --on-active=1 systemctl reboot") } // IsChina 是否中国大陆 diff --git a/web/src/views/firewall/IndexView.vue b/web/src/views/firewall/IndexView.vue index 71d86dc69..1a8c90139 100644 --- a/web/src/views/firewall/IndexView.vue +++ b/web/src/views/firewall/IndexView.vue @@ -20,12 +20,14 @@ const currentTab = ref('rule') // n-tabs 对 v-if 动态增删子 tab 不会重算指示条位置,用 key 强制重挂 const ruleTabs = ['rule', 'ip-rule', 'forward'] const firewallRunning = ref(false) +const firewallName = ref('') const ready = ref(false) const { send: refreshStatus } = useRequest(firewall.status) .onSuccess(({ data }) => { - firewallRunning.value = data - if (!data && ruleTabs.includes(currentTab.value)) currentTab.value = 'scan' + firewallRunning.value = data.running + firewallName.value = data.name + if (!data.running && ruleTabs.includes(currentTab.value)) currentTab.value = 'scan' }) .onComplete(() => { ready.value = true @@ -52,14 +54,20 @@ onUnmounted(() => { - + diff --git a/web/src/views/firewall/SettingView.vue b/web/src/views/firewall/SettingView.vue index 8aab68977..c4b6b5c66 100644 --- a/web/src/views/firewall/SettingView.vue +++ b/web/src/views/firewall/SettingView.vue @@ -11,7 +11,7 @@ const model = ref({ }) useRequest(firewall.status).onSuccess(({ data }) => { - model.value.firewallStatus = data + model.value.firewallStatus = data.running }) useRequest(safe.pingStatus).onSuccess(({ data }) => { model.value.pingStatus = data