diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go
index f4297b79..64e72e1a 100644
--- a/cmd/ace/wire_gen.go
+++ b/cmd/ace/wire_gen.go
@@ -118,6 +118,8 @@ func initWeb() (*app.Web, error) {
systemctlService := service.NewSystemctlService(locale)
toolboxSystemService := service.NewToolboxSystemService(locale)
toolboxBenchmarkService := service.NewToolboxBenchmarkService(locale)
+ webHookRepo := data.NewWebHookRepo(locale, db)
+ webHookService := service.NewWebHookService(webHookRepo)
codeserverApp := codeserver.NewApp()
dockerApp := docker.NewApp()
fail2banApp := fail2ban.NewApp(locale, websiteRepo)
@@ -139,7 +141,7 @@ func initWeb() (*app.Web, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
- http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, loader)
+ http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, webHookService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo)
ws := route.NewWs(wsService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
diff --git a/internal/biz/webhook.go b/internal/biz/webhook.go
new file mode 100644
index 00000000..fca5b016
--- /dev/null
+++ b/internal/biz/webhook.go
@@ -0,0 +1,31 @@
+package biz
+
+import (
+ "time"
+
+ "github.com/acepanel/panel/internal/http/request"
+)
+
+type WebHook struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Name string `gorm:"not null;default:''" json:"name"` // 钩子名称
+ Key string `gorm:"not null;uniqueIndex" json:"key"` // 唯一标识(用于 URL)
+ Script string `gorm:"not null;default:''" json:"script"` // 脚本内容
+ Raw bool `gorm:"not null;default:false" json:"raw"` // 是否以原始格式返回输出
+ User string `gorm:"not null;default:''" json:"user"` // 以哪个用户身份执行脚本
+ Status bool `gorm:"not null;default:true" json:"status"` // 启用状态
+ CallCount uint `gorm:"not null;default:0" json:"call_count"` // 调用次数
+ LastCallAt time.Time `json:"last_call_at"` // 上次调用时间
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type WebHookRepo interface {
+ List(page, limit uint) ([]*WebHook, int64, error)
+ Get(id uint) (*WebHook, error)
+ GetByKey(key string) (*WebHook, error)
+ Create(req *request.WebHookCreate) (*WebHook, error)
+ Update(req *request.WebHookUpdate) error
+ Delete(id uint) error
+ Call(key string) (string, error)
+}
diff --git a/internal/data/data.go b/internal/data/data.go
index d3a28eed..a430a0c1 100644
--- a/internal/data/data.go
+++ b/internal/data/data.go
@@ -27,5 +27,6 @@ var ProviderSet = wire.NewSet(
NewTaskRepo,
NewUserRepo,
NewUserTokenRepo,
+ NewWebHookRepo,
NewWebsiteRepo,
)
diff --git a/internal/data/webhook.go b/internal/data/webhook.go
new file mode 100644
index 00000000..c8638951
--- /dev/null
+++ b/internal/data/webhook.go
@@ -0,0 +1,162 @@
+package data
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+
+ "github.com/leonelquinteros/gotext"
+ "github.com/libtnb/utils/str"
+ "gorm.io/gorm"
+
+ "github.com/acepanel/panel/internal/app"
+ "github.com/acepanel/panel/internal/biz"
+ "github.com/acepanel/panel/internal/http/request"
+ "github.com/acepanel/panel/pkg/io"
+)
+
+type webhookRepo struct {
+ t *gotext.Locale
+ db *gorm.DB
+}
+
+func NewWebHookRepo(t *gotext.Locale, db *gorm.DB) biz.WebHookRepo {
+ return &webhookRepo{
+ t: t,
+ db: db,
+ }
+}
+
+func (r *webhookRepo) List(page, limit uint) ([]*biz.WebHook, int64, error) {
+ webhooks := make([]*biz.WebHook, 0)
+ var total int64
+ err := r.db.Model(&biz.WebHook{}).Order("id desc").Count(&total).Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&webhooks).Error
+ return webhooks, total, err
+}
+
+func (r *webhookRepo) Get(id uint) (*biz.WebHook, error) {
+ webhook := new(biz.WebHook)
+ if err := r.db.Where("id = ?", id).First(webhook).Error; err != nil {
+ return nil, err
+ }
+ return webhook, nil
+}
+
+func (r *webhookRepo) GetByKey(key string) (*biz.WebHook, error) {
+ webhook := new(biz.WebHook)
+ if err := r.db.Where("`key` = ?", key).First(webhook).Error; err != nil {
+ return nil, err
+ }
+ return webhook, nil
+}
+
+func (r *webhookRepo) Create(req *request.WebHookCreate) (*biz.WebHook, error) {
+ if err := os.MkdirAll(r.webhookDir(), 0755); err != nil {
+ return nil, errors.New(r.t.Get("failed to create webhook directory: %v", err))
+ }
+
+ key := str.Random(32)
+ scriptFile := r.scriptPath(key)
+ if err := io.Write(scriptFile, req.Script, 0755); err != nil {
+ return nil, errors.New(r.t.Get("failed to write webhook script: %v", err))
+ }
+
+ webhook := &biz.WebHook{
+ Name: req.Name,
+ Key: key,
+ Script: req.Script,
+ Raw: req.Raw,
+ User: req.User,
+ Status: true,
+ }
+
+ if err := r.db.Create(webhook).Error; err != nil {
+ _ = os.Remove(scriptFile)
+ return nil, err
+ }
+
+ return webhook, nil
+}
+
+func (r *webhookRepo) Update(req *request.WebHookUpdate) error {
+ webhook, err := r.Get(req.ID)
+ if err != nil {
+ return err
+ }
+
+ scriptFile := r.scriptPath(webhook.Key)
+ if err = io.Write(scriptFile, req.Script, 0755); err != nil {
+ return errors.New(r.t.Get("failed to write webhook script: %v", err))
+ }
+
+ return r.db.Model(&biz.WebHook{}).Where("id = ?", req.ID).Updates(map[string]any{
+ "name": req.Name,
+ "script": req.Script,
+ "raw": req.Raw,
+ "user": req.User,
+ "status": req.Status,
+ }).Error
+}
+
+func (r *webhookRepo) Delete(id uint) error {
+ webhook, err := r.Get(id)
+ if err != nil {
+ return err
+ }
+
+ scriptFile := r.scriptPath(webhook.Key)
+ _ = os.Remove(scriptFile)
+
+ return r.db.Delete(&biz.WebHook{}, id).Error
+}
+
+func (r *webhookRepo) Call(key string) (string, error) {
+ webhook, err := r.GetByKey(key)
+ if err != nil {
+ return "", errors.New(r.t.Get("webhook not found"))
+ }
+
+ if !webhook.Status {
+ return "", errors.New(r.t.Get("webhook is disabled"))
+ }
+
+ scriptFile := r.scriptPath(key)
+ if !io.Exists(scriptFile) {
+ return "", errors.New(r.t.Get("webhook script not found"))
+ }
+
+ // 执行脚本
+ var cmd *exec.Cmd
+ if webhook.User == "" || webhook.User == "root" {
+ cmd = exec.Command("bash", scriptFile)
+ } else {
+ cmd = exec.Command("su", "-s", "/bin/bash", "-c", fmt.Sprintf("bash %s", scriptFile), webhook.User)
+ }
+
+ output, err := cmd.CombinedOutput()
+
+ // 更新调用统计
+ _ = r.db.Model(&biz.WebHook{}).Where("`key` = ?", key).Updates(map[string]any{
+ "call_count": gorm.Expr("call_count + 1"),
+ "last_call_at": time.Now(),
+ }).Error
+
+ if err != nil {
+ return string(output), fmt.Errorf("script execution failed: %w, output: %s", err, string(output))
+ }
+
+ return string(output), nil
+}
+
+// webhookDir 返回 webhook 脚本存储目录
+func (r *webhookRepo) webhookDir() string {
+ return filepath.Join(app.Root, "server", "webhook")
+}
+
+// scriptPath 返回指定 key 的脚本路径
+func (r *webhookRepo) scriptPath(key string) string {
+ return filepath.Join(r.webhookDir(), key+".sh")
+}
diff --git a/internal/http/middleware/entrance.go b/internal/http/middleware/entrance.go
index 31be250f..76eb04c3 100644
--- a/internal/http/middleware/entrance.go
+++ b/internal/http/middleware/entrance.go
@@ -112,7 +112,13 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
return
}
- // 情况四:非调试模式且未通过验证的请求,返回错误
+ // 情况四:Webhook 访问,跳过验证
+ if strings.HasPrefix(r.URL.Path, "/webhook/") {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // 情况五:非调试模式且未通过验证的请求,返回错误
if !conf.App.Debug &&
sess.Missing("verify_entrance") &&
r.URL.Path != "/robots.txt" {
diff --git a/internal/http/request/webhook.go b/internal/http/request/webhook.go
new file mode 100644
index 00000000..99b5a929
--- /dev/null
+++ b/internal/http/request/webhook.go
@@ -0,0 +1,21 @@
+package request
+
+type WebHookCreate struct {
+ Name string `json:"name" form:"name" validate:"required"`
+ Script string `json:"script" form:"script" validate:"required"`
+ Raw bool `json:"raw" form:"raw"`
+ User string `json:"user" form:"user"`
+}
+
+type WebHookUpdate struct {
+ ID uint `json:"id" form:"id" uri:"id" validate:"required|exists:web_hooks,id"`
+ Name string `json:"name" form:"name" validate:"required"`
+ Script string `json:"script" form:"script" validate:"required"`
+ Raw bool `json:"raw" form:"raw"`
+ User string `json:"user" form:"user" validate:"required"`
+ Status bool `json:"status" form:"status"`
+}
+
+type WebHookKey struct {
+ Key string `json:"key" form:"key" uri:"key" validate:"required"`
+}
diff --git a/internal/migration/v1.go b/internal/migration/v1.go
index 4c74cb55..4833227d 100644
--- a/internal/migration/v1.go
+++ b/internal/migration/v1.go
@@ -9,107 +9,45 @@ import (
func init() {
Migrations = append(Migrations, &gormigrate.Migration{
- ID: "20240812-init",
+ ID: "20260101-init",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
+ &biz.App{},
&biz.Cache{},
&biz.Cert{},
- &biz.CertDNS{},
&biz.CertAccount{},
- &biz.Cron{},
- &biz.Monitor{},
- &biz.App{},
- &biz.Setting{},
- &biz.Task{},
- &biz.User{},
- &biz.Website{},
- )
- },
- Rollback: func(tx *gorm.DB) error {
- return tx.Migrator().DropTable(
- &biz.Cert{},
&biz.CertDNS{},
- &biz.CertAccount{},
&biz.Cron{},
- &biz.Monitor{},
- &biz.App{},
- &biz.Setting{},
- &biz.Task{},
- &biz.User{},
- &biz.Website{},
- )
- },
- })
- Migrations = append(Migrations, &gormigrate.Migration{
- ID: "20241022-ssh",
- Migrate: func(tx *gorm.DB) error {
- return tx.AutoMigrate(
- &biz.SSH{},
- )
- },
- Rollback: func(tx *gorm.DB) error {
- return tx.Migrator().DropTable(
- &biz.SSH{},
- )
- },
- })
- Migrations = append(Migrations, &gormigrate.Migration{
- ID: "20241124-database",
- Migrate: func(tx *gorm.DB) error {
- _ = tx.Migrator().DropTable("databases")
- return tx.AutoMigrate(
&biz.DatabaseServer{},
&biz.DatabaseUser{},
- )
- },
- Rollback: func(tx *gorm.DB) error {
- return tx.Migrator().DropTable(
- &biz.DatabaseServer{},
- &biz.DatabaseUser{},
- )
- },
- })
- Migrations = append(Migrations, &gormigrate.Migration{
- ID: "20250318-cert-script",
- Migrate: func(tx *gorm.DB) error {
- return tx.AutoMigrate(
- &biz.Cache{},
- &biz.Cert{},
- &biz.CertDNS{},
- &biz.CertAccount{},
- &biz.Cron{},
&biz.Monitor{},
- &biz.App{},
&biz.Setting{},
+ &biz.SSH{},
&biz.Task{},
&biz.User{},
- &biz.Website{},
- &biz.SSH{},
- &biz.DatabaseServer{},
- &biz.DatabaseUser{},
- )
- },
- Rollback: func(tx *gorm.DB) error {
- return tx.Migrator().DropColumn(&biz.Cert{}, "script")
- },
- })
- Migrations = append(Migrations, &gormigrate.Migration{
- ID: "20250514-user-website",
- Migrate: func(tx *gorm.DB) error {
- return tx.AutoMigrate(
- &biz.User{},
- &biz.Website{},
&biz.UserToken{},
+ &biz.WebHook{},
+ &biz.Website{},
)
},
Rollback: func(tx *gorm.DB) error {
- if err := tx.Migrator().DropColumn(&biz.User{}, "two_fa"); err != nil {
- return err
- }
- if err := tx.Migrator().DropColumn(&biz.Website{}, "type"); err != nil {
- return err
- }
- return tx.Migrator().DropTable(&biz.UserToken{})
+ return tx.Migrator().DropTable(
+ &biz.App{},
+ &biz.Cert{},
+ &biz.CertAccount{},
+ &biz.CertDNS{},
+ &biz.Cron{},
+ &biz.DatabaseServer{},
+ &biz.DatabaseUser{},
+ &biz.Monitor{},
+ &biz.Setting{},
+ &biz.SSH{},
+ &biz.Task{},
+ &biz.User{},
+ &biz.UserToken{},
+ &biz.WebHook{},
+ &biz.Website{},
+ )
},
})
}
diff --git a/internal/route/http.go b/internal/route/http.go
index 137bc80d..49831ff1 100644
--- a/internal/route/http.go
+++ b/internal/route/http.go
@@ -48,6 +48,7 @@ type Http struct {
systemctl *service.SystemctlService
toolboxSystem *service.ToolboxSystemService
toolboxBenchmark *service.ToolboxBenchmarkService
+ webhook *service.WebHookService
apps *apploader.Loader
}
@@ -84,6 +85,7 @@ func NewHttp(
systemctl *service.SystemctlService,
toolboxSystem *service.ToolboxSystemService,
toolboxBenchmark *service.ToolboxBenchmarkService,
+ webhook *service.WebHookService,
apps *apploader.Loader,
) *Http {
return &Http{
@@ -119,6 +121,7 @@ func NewHttp(
systemctl: systemctl,
toolboxSystem: toolboxSystem,
toolboxBenchmark: toolboxBenchmark,
+ webhook: webhook,
apps: apps,
}
}
@@ -442,11 +445,23 @@ func (route *Http) Register(r *chi.Mux) {
r.Post("/test", route.toolboxBenchmark.Test)
})
+ r.Route("/webhook", func(r chi.Router) {
+ r.Get("/", route.webhook.List)
+ r.Post("/", route.webhook.Create)
+ r.Put("/{id}", route.webhook.Update)
+ r.Get("/{id}", route.webhook.Get)
+ r.Delete("/{id}", route.webhook.Delete)
+ })
+
r.Route("/apps", func(r chi.Router) {
route.apps.Register(r)
})
})
+ // WebHook 调用接口
+ r.Get("/webhook/{key}", route.webhook.Call)
+ r.Post("/webhook/{key}", route.webhook.Call)
+
r.NotFound(func(writer http.ResponseWriter, request *http.Request) {
// /api 开头的返回 404
if strings.HasPrefix(request.URL.Path, "/api") {
diff --git a/internal/service/service.go b/internal/service/service.go
index 5ea91e58..50ac3f25 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -33,6 +33,7 @@ var ProviderSet = wire.NewSet(
NewTaskService,
NewUserService,
NewUserTokenService,
+ NewWebHookService,
NewWebsiteService,
NewToolboxSystemService,
NewToolboxBenchmarkService,
diff --git a/internal/service/webhook.go b/internal/service/webhook.go
new file mode 100644
index 00000000..8b8aee0c
--- /dev/null
+++ b/internal/service/webhook.go
@@ -0,0 +1,139 @@
+package service
+
+import (
+ "net/http"
+
+ "github.com/libtnb/chix"
+
+ "github.com/acepanel/panel/internal/biz"
+ "github.com/acepanel/panel/internal/http/request"
+)
+
+type WebHookService struct {
+ webhookRepo biz.WebHookRepo
+}
+
+func NewWebHookService(webhook biz.WebHookRepo) *WebHookService {
+ return &WebHookService{
+ webhookRepo: webhook,
+ }
+}
+
+func (s *WebHookService) List(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.Paginate](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ webhooks, total, err := s.webhookRepo.List(req.Page, req.Limit)
+ if err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, chix.M{
+ "total": total,
+ "items": webhooks,
+ })
+}
+
+func (s *WebHookService) Get(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.ID](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ webhook, err := s.webhookRepo.Get(req.ID)
+ if err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, webhook)
+}
+
+func (s *WebHookService) Create(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.WebHookCreate](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ webhook, err := s.webhookRepo.Create(req)
+ if err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, webhook)
+}
+
+func (s *WebHookService) Update(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.WebHookUpdate](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ if err = s.webhookRepo.Update(req); err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, nil)
+}
+
+func (s *WebHookService) Delete(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.ID](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ if err = s.webhookRepo.Delete(req.ID); err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, nil)
+}
+
+// Call 处理 webhook 调用请求
+func (s *WebHookService) Call(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.WebHookKey](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ // 获取 webhook 信息以判断返回格式
+ webhook, err := s.webhookRepo.GetByKey(req.Key)
+ if err != nil {
+ Error(w, http.StatusNotFound, "webhook not found")
+ return
+ }
+
+ output, err := s.webhookRepo.Call(req.Key)
+ if err != nil {
+ if webhook.Raw {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(output))
+ return
+ }
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ if webhook.Raw {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(output))
+ return
+ }
+
+ Success(w, chix.M{
+ "output": output,
+ })
+}
diff --git a/web/src/api/panel/webhook/index.ts b/web/src/api/panel/webhook/index.ts
new file mode 100644
index 00000000..4a742e78
--- /dev/null
+++ b/web/src/api/panel/webhook/index.ts
@@ -0,0 +1,14 @@
+import { http } from '@/utils'
+
+export default {
+ // 获取 WebHook 列表
+ list: (page: number, limit: number): any => http.Get('/webhook', { params: { page, limit } }),
+ // 获取 WebHook 信息
+ get: (id: number): any => http.Get(`/webhook/${id}`),
+ // 创建 WebHook
+ create: (req: any): any => http.Post('/webhook', req),
+ // 修改 WebHook
+ update: (id: number, req: any): any => http.Put(`/webhook/${id}`, req),
+ // 删除 WebHook
+ delete: (id: number): any => http.Delete(`/webhook/${id}`)
+}
diff --git a/web/src/views/toolbox/IndexView.vue b/web/src/views/toolbox/IndexView.vue
index 02a7d634..806d6635 100644
--- a/web/src/views/toolbox/IndexView.vue
+++ b/web/src/views/toolbox/IndexView.vue
@@ -6,6 +6,7 @@ defineOptions({
import BenchmarkView from '@/views/toolbox/BenchmarkView.vue'
import ProcessView from '@/views/toolbox/ProcessView.vue'
import SystemView from '@/views/toolbox/SystemView.vue'
+import WebHookView from '@/views/toolbox/WebHookView.vue'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
@@ -18,12 +19,14 @@ const current = ref('process')
+
+
diff --git a/web/src/views/toolbox/WebHookView.vue b/web/src/views/toolbox/WebHookView.vue
new file mode 100644
index 00000000..7cde63f2
--- /dev/null
+++ b/web/src/views/toolbox/WebHookView.vue
@@ -0,0 +1,394 @@
+
+
+
+
+
+
+ {{ $gettext('Create WebHook') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $gettext('Return script output as raw text instead of JSON') }}
+
+
+
+
+
+
+
+ {{ $gettext('Create') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $gettext('Return script output as raw text instead of JSON') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $gettext('Save') }}
+
+
+
+
+