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(() => {
-
+
+
+ {{ $gettext('Current firewall: %{ name }', { name: firewallName }) }}
+
+
+ {{ $gettext('No supported firewall detected, please install firewalld or ufw') }}
+
-
+
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