mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-31 01:12:17 +08:00
feat(waf): 接入 acewaf 管理模块
This commit is contained in:
+18
-1
@@ -8,6 +8,7 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/acepanel/panel/v3/internal/app"
|
||||
"github.com/acepanel/panel/v3/internal/apps/acewaf"
|
||||
"github.com/acepanel/panel/v3/internal/apps/apache"
|
||||
"github.com/acepanel/panel/v3/internal/apps/clickhouse"
|
||||
"github.com/acepanel/panel/v3/internal/apps/codeserver"
|
||||
@@ -56,6 +57,10 @@ import (
|
||||
// Injectors from wire.go:
|
||||
|
||||
func initAce() (*app.Ace, func(), error) {
|
||||
acewafApp, err := acewaf.NewApp()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
config, err := bootstrap.NewConf()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -237,7 +242,7 @@ func initAce() (*app.Ace, func(), error) {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
loader, err := bootstrap.NewLoader(apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, pgadminApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
|
||||
loader, err := bootstrap.NewLoader(acewafApp, apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, pgadminApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
@@ -807,6 +812,17 @@ func initAce() (*app.Ace, func(), error) {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
wafRepo, err := data.NewWafRepo(locale, db, slogLogger, settingRepo)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
wafUsecase := biz.NewWafUsecase(wafRepo)
|
||||
wafService, err := service.NewWafService(wafUsecase, locale)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
wsService, err := service.NewWsService(backupUsecase, certUsecase, sshUsecase, settingUsecase, taskUsecase, config, locale, slogLogger)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
@@ -868,6 +884,7 @@ func initAce() (*app.Ace, func(), error) {
|
||||
WebHook: webHookService,
|
||||
Website: websiteService,
|
||||
WebsiteStat: websiteStatService,
|
||||
Waf: wafService,
|
||||
Ws: wsService,
|
||||
}
|
||||
v := route.NewEndpoints(services)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package acewaf
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/acepanel/panel/v3/pkg/systemctl"
|
||||
"github.com/acepanel/panel/v3/pkg/types"
|
||||
)
|
||||
|
||||
type App struct{}
|
||||
|
||||
func NewApp() (*App, error) {
|
||||
return &App{}, nil
|
||||
}
|
||||
|
||||
func (s *App) Route(_ chi.Router) {
|
||||
// WAF 管理路由由 route 贡献(route/waf.go)统一提供
|
||||
}
|
||||
|
||||
func (s *App) Status() string {
|
||||
ok, _ := systemctl.Status("acewaf")
|
||||
return types.AggregateAppStatus(ok)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package apps
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/apps/acewaf"
|
||||
"github.com/acepanel/panel/v3/internal/apps/apache"
|
||||
"github.com/acepanel/panel/v3/internal/apps/clickhouse"
|
||||
"github.com/acepanel/panel/v3/internal/apps/codeserver"
|
||||
@@ -47,4 +48,5 @@ var ProviderSet = wire.NewSet(
|
||||
postgresql.NewApp, prometheus.NewApp, pureftpd.NewApp,
|
||||
redis.NewApp, rocketmq.NewApp, rsync.NewApp,
|
||||
s3fs.NewApp, supervisor.NewApp, valkey.NewApp,
|
||||
acewaf.NewApp,
|
||||
)
|
||||
|
||||
+1
-1
@@ -14,5 +14,5 @@ var ProviderSet = wire.NewSet(
|
||||
NewSettingUsecase, NewSSHUsecase, NewTamperUsecase, NewTaskUsecase,
|
||||
NewTemplateUsecase, NewUserUsecase, NewUserPasskeyUsecase,
|
||||
NewUserTokenUsecase, NewWebHookUsecase, NewWebsiteUsecase,
|
||||
NewWebsiteStatUsecase,
|
||||
NewWebsiteStatUsecase, NewWafUsecase,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
OperationTypeMonitor = "monitor"
|
||||
OperationTypeWebhook = "webhook"
|
||||
OperationTypeUser = "user"
|
||||
OperationTypeWaf = "waf"
|
||||
)
|
||||
|
||||
// LogEntry 日志条目
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package biz
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/request"
|
||||
)
|
||||
|
||||
type WafPolicyApplyState string
|
||||
|
||||
const (
|
||||
WafPolicyApplyStateSaved WafPolicyApplyState = "saved"
|
||||
WafPolicyApplyStatePending WafPolicyApplyState = "pending"
|
||||
WafPolicyApplyStateApplied WafPolicyApplyState = "applied"
|
||||
WafPolicyApplyStateFailed WafPolicyApplyState = "failed"
|
||||
)
|
||||
|
||||
// WafBinding 网站与 WAF 策略的绑定关系
|
||||
type WafBinding struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
WebsiteID uint `gorm:"not null;uniqueIndex;default:0" json:"website_id"` // 面板网站 ID 一个网站至多一条绑定
|
||||
PolicyID uint64 `gorm:"not null;default:0" json:"policy_id"` // agent 侧策略 ID
|
||||
Enabled bool `gorm:"not null;default:false" json:"enabled"` // 是否已写入 nginx 并启用
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
WebsiteName string `gorm:"-:all" json:"website_name"` // 仅显示
|
||||
}
|
||||
|
||||
// WafRepo 单机
|
||||
type WafRepo interface {
|
||||
// 透传 agent 策略
|
||||
ListPolicies() (any, error)
|
||||
GetPolicy(policyID string) (any, error)
|
||||
GetPolicyStatus(policyID string) (any, error)
|
||||
CreatePolicy(body any) (any, error)
|
||||
UpdatePolicy(policyID string, body any) (any, error)
|
||||
DeletePolicy(policyID string) error
|
||||
|
||||
// 透传 agent 决策(黑白名单/拉黑)
|
||||
ListDecisions(query map[string]string) (any, error)
|
||||
CreateDecision(req *request.WafDecisionCreate) (any, error)
|
||||
DeleteDecision(query map[string]string) error
|
||||
|
||||
// 透传 agent 误报加白
|
||||
ListExclusions(policyID string) (any, error)
|
||||
CreateExclusion(policyID string, body any) (any, error)
|
||||
DeleteExclusion(policyID string, query map[string]string) error
|
||||
|
||||
// 透传 agent 报表
|
||||
Events(query map[string]string) (any, error)
|
||||
Stats(query map[string]string) (any, error)
|
||||
AttackMap(query map[string]string) (any, error)
|
||||
|
||||
// 网站绑定 + nginx 启停
|
||||
ListBindings() ([]*WafBinding, error)
|
||||
EnableWebsite(req *request.WafWebsiteToggle) error
|
||||
DisableWebsite(websiteID uint) error
|
||||
}
|
||||
|
||||
// WafUsecase WAF 用例:编排与外部交互均封装在 repo 原语,此处透传
|
||||
type WafUsecase struct {
|
||||
repo WafRepo
|
||||
}
|
||||
|
||||
func NewWafUsecase(repo WafRepo) *WafUsecase {
|
||||
return &WafUsecase{repo: repo}
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) ListPolicies() (any, error) { return uc.repo.ListPolicies() }
|
||||
|
||||
func (uc *WafUsecase) GetPolicy(policyID string) (any, error) { return uc.repo.GetPolicy(policyID) }
|
||||
|
||||
func (uc *WafUsecase) GetPolicyStatus(policyID string) (any, error) {
|
||||
return uc.repo.GetPolicyStatus(policyID)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) CreatePolicy(body any) (any, error) { return uc.repo.CreatePolicy(body) }
|
||||
|
||||
func (uc *WafUsecase) UpdatePolicy(policyID string, body any) (any, error) {
|
||||
return uc.repo.UpdatePolicy(policyID, body)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) DeletePolicy(policyID string) error { return uc.repo.DeletePolicy(policyID) }
|
||||
|
||||
func (uc *WafUsecase) ListDecisions(query map[string]string) (any, error) {
|
||||
return uc.repo.ListDecisions(query)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) CreateDecision(req *request.WafDecisionCreate) (any, error) {
|
||||
return uc.repo.CreateDecision(req)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) DeleteDecision(query map[string]string) error {
|
||||
return uc.repo.DeleteDecision(query)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) ListExclusions(policyID string) (any, error) {
|
||||
return uc.repo.ListExclusions(policyID)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) CreateExclusion(policyID string, body any) (any, error) {
|
||||
return uc.repo.CreateExclusion(policyID, body)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) DeleteExclusion(policyID string, query map[string]string) error {
|
||||
return uc.repo.DeleteExclusion(policyID, query)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) Events(query map[string]string) (any, error) { return uc.repo.Events(query) }
|
||||
|
||||
func (uc *WafUsecase) Stats(query map[string]string) (any, error) { return uc.repo.Stats(query) }
|
||||
|
||||
func (uc *WafUsecase) AttackMap(query map[string]string) (any, error) {
|
||||
return uc.repo.AttackMap(query)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) ListBindings() ([]*WafBinding, error) { return uc.repo.ListBindings() }
|
||||
|
||||
func (uc *WafUsecase) EnableWebsite(req *request.WafWebsiteToggle) error {
|
||||
return uc.repo.EnableWebsite(req)
|
||||
}
|
||||
|
||||
func (uc *WafUsecase) DisableWebsite(websiteID uint) error { return uc.repo.DisableWebsite(websiteID) }
|
||||
@@ -1,6 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/acepanel/panel/v3/internal/apps/acewaf"
|
||||
"github.com/acepanel/panel/v3/internal/apps/apache"
|
||||
"github.com/acepanel/panel/v3/internal/apps/clickhouse"
|
||||
"github.com/acepanel/panel/v3/internal/apps/codeserver"
|
||||
@@ -35,8 +36,8 @@ import (
|
||||
"github.com/acepanel/panel/v3/pkg/apploader"
|
||||
)
|
||||
|
||||
func NewLoader(apacheApp *apache.App, clickhouseApp *clickhouse.App, codeserverApp *codeserver.App, dockerApp *docker.App, elasticsearchApp *elasticsearch.App, fail2banApp *fail2ban.App, frpApp *frp.App, giteaApp *gitea.App, grafanaApp *grafana.App, kafkaApp *kafka.App, mariadbApp *mariadb.App, memcachedApp *memcached.App, minioApp *minio.App, mongodbApp *mongodb.App, mysqlApp *mysql.App, nginxApp *nginx.App, openrestyApp *openresty.App, opensearchApp *opensearch.App, perconaApp *percona.App, pgadminApp *pgadmin.App, phpmyadminApp *phpmyadmin.App, podmanApp *podman.App, postgresqlApp *postgresql.App, prometheusApp *prometheus.App, pureftpdApp *pureftpd.App, redisApp *redis.App, rocketmqApp *rocketmq.App, rsyncApp *rsync.App, s3fsApp *s3fs.App, supervisorApp *supervisor.App, valkeyApp *valkey.App) (*apploader.Loader, error) {
|
||||
func NewLoader(acewafApp *acewaf.App, apacheApp *apache.App, clickhouseApp *clickhouse.App, codeserverApp *codeserver.App, dockerApp *docker.App, elasticsearchApp *elasticsearch.App, fail2banApp *fail2ban.App, frpApp *frp.App, giteaApp *gitea.App, grafanaApp *grafana.App, kafkaApp *kafka.App, mariadbApp *mariadb.App, memcachedApp *memcached.App, minioApp *minio.App, mongodbApp *mongodb.App, mysqlApp *mysql.App, nginxApp *nginx.App, openrestyApp *openresty.App, opensearchApp *opensearch.App, perconaApp *percona.App, pgadminApp *pgadmin.App, phpmyadminApp *phpmyadmin.App, podmanApp *podman.App, postgresqlApp *postgresql.App, prometheusApp *prometheus.App, pureftpdApp *pureftpd.App, redisApp *redis.App, rocketmqApp *rocketmq.App, rsyncApp *rsync.App, s3fsApp *s3fs.App, supervisorApp *supervisor.App, valkeyApp *valkey.App) (*apploader.Loader, error) {
|
||||
loader := new(apploader.Loader)
|
||||
loader.Add(apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, pgadminApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
|
||||
loader.Add(acewafApp, apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, pgadminApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
|
||||
return loader, nil
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@ var ProviderSet = wire.NewSet(
|
||||
NewSettingRepo, NewSSHRepo, NewTamperRepo, NewTaskRepo,
|
||||
NewTemplateRepo, NewUserRepo, NewUserPasskeyRepo,
|
||||
NewUserTokenRepo, NewWebHookRepo, NewWebsiteRepo,
|
||||
NewWebsiteStatRepo,
|
||||
NewWebsiteStatRepo, NewWafRepo,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"resty.dev/v3"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/app"
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
"github.com/acepanel/panel/v3/internal/request"
|
||||
"github.com/acepanel/panel/v3/pkg/shell"
|
||||
"github.com/acepanel/panel/v3/pkg/systemctl"
|
||||
"github.com/acepanel/panel/v3/pkg/webserver"
|
||||
webservertypes "github.com/acepanel/panel/v3/pkg/webserver/types"
|
||||
)
|
||||
|
||||
// wafConfigName WAF 站点配置片段文件名(序号靠后,确保在基础指令之后加载)
|
||||
const wafConfigName = "011-waf.conf"
|
||||
|
||||
// wafManageSocket 本地 acewaf 管理 unix socket(单机直连,socket 文件权限鉴权)
|
||||
const wafManageSocket = "/opt/ace/waf/run/acewaf-manage.sock"
|
||||
|
||||
type wafRepo struct {
|
||||
t *gotext.Locale
|
||||
db *gorm.DB
|
||||
log *slog.Logger
|
||||
setting biz.SettingRepo
|
||||
client *resty.Client
|
||||
bindMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewWafRepo(t *gotext.Locale, db *gorm.DB, log *slog.Logger, setting biz.SettingRepo) (biz.WafRepo, error) {
|
||||
client := resty.New()
|
||||
client.SetTimeout(65 * time.Second)
|
||||
client.SetTransport(&http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", wafManageSocket)
|
||||
},
|
||||
})
|
||||
client.SetBaseURL("http://acewaf")
|
||||
|
||||
return &wafRepo{
|
||||
t: t,
|
||||
db: db,
|
||||
log: log,
|
||||
setting: setting,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ===================== agent HTTP 透传(本地 unix socket) =====================
|
||||
|
||||
type wafError struct {
|
||||
status int
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *wafError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e *wafError) StatusCode() int {
|
||||
return e.status
|
||||
}
|
||||
|
||||
// do 通用透传:发起请求并把 agent 的 JSON 原样解析为 any 返回,对 agent 字段新增保持健壮
|
||||
func (r *wafRepo) do(method, path string, query map[string]string, body any) (any, error) {
|
||||
req := r.client.R()
|
||||
if len(query) > 0 {
|
||||
req.SetQueryParams(query)
|
||||
}
|
||||
if body != nil {
|
||||
req.SetHeader("Content-Type", "application/json").SetBody(body)
|
||||
}
|
||||
|
||||
resp, err := req.Execute(method, path)
|
||||
if err != nil {
|
||||
return nil, errors.New(r.t.Get("failed to request acewaf: %v", err))
|
||||
}
|
||||
if !resp.IsStatusSuccess() {
|
||||
return nil, &wafError{
|
||||
status: resp.StatusCode(),
|
||||
message: r.t.Get("acewaf returned an error (%d): %s", resp.StatusCode(), resp.String()),
|
||||
}
|
||||
}
|
||||
if len(resp.Bytes()) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result any
|
||||
decoder := json.NewDecoder(bytes.NewReader(resp.Bytes()))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return nil, errors.New(r.t.Get("failed to parse acewaf response: %v", err))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *wafRepo) ListPolicies() (any, error) {
|
||||
data, err := r.do(resty.MethodGet, "/api/policies", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.withPolicyApplyState(data)
|
||||
}
|
||||
|
||||
func (r *wafRepo) GetPolicy(policyID string) (any, error) {
|
||||
data, err := r.do(resty.MethodGet, fmt.Sprintf("/api/policies/%s", policyID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.withPolicyApplyState(data)
|
||||
}
|
||||
|
||||
func (r *wafRepo) GetPolicyStatus(policyID string) (any, error) {
|
||||
data, err := r.do(resty.MethodGet, fmt.Sprintf("/api/policies/%s/status", policyID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.withPolicyApplyState(data)
|
||||
}
|
||||
|
||||
func (r *wafRepo) CreatePolicy(body any) (any, error) {
|
||||
data, err := r.do(resty.MethodPost, "/api/policies", nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizePolicyStatus(data, nil), nil
|
||||
}
|
||||
|
||||
func (r *wafRepo) UpdatePolicy(policyID string, body any) (any, error) {
|
||||
boundPolicyIDs, err := r.boundPolicyIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := r.do(resty.MethodPut, fmt.Sprintf("/api/policies/%s", policyID), nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizePolicyStatus(data, boundPolicyIDs), nil
|
||||
}
|
||||
|
||||
func (r *wafRepo) withPolicyApplyState(data any) (any, error) {
|
||||
boundPolicyIDs, err := r.boundPolicyIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizePolicyStatus(data, boundPolicyIDs), nil
|
||||
}
|
||||
|
||||
func (r *wafRepo) boundPolicyIDs() (map[uint64]struct{}, error) {
|
||||
var policyIDs []uint64
|
||||
if err := r.db.Model(&biz.WafBinding{}).
|
||||
Where("enabled = ?", true).
|
||||
Distinct().
|
||||
Pluck("policy_id", &policyIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boundPolicyIDs := make(map[uint64]struct{}, len(policyIDs))
|
||||
for _, policyID := range policyIDs {
|
||||
boundPolicyIDs[policyID] = struct{}{}
|
||||
}
|
||||
return boundPolicyIDs, nil
|
||||
}
|
||||
|
||||
func normalizePolicyStatus(data any, boundPolicyIDs map[uint64]struct{}) any {
|
||||
normalize := func(policy map[string]any) {
|
||||
policyID, ok := policyIDFromResponse(policy)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := policy["target_version"]; !ok {
|
||||
if _, hasAppliedVersion := policy["applied_version"]; hasAppliedVersion {
|
||||
policy["target_version"] = policy["version"]
|
||||
} else {
|
||||
policy["target_version"] = nil
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"applied_version", "last_error"} {
|
||||
if _, ok := policy[field]; !ok {
|
||||
policy[field] = nil
|
||||
}
|
||||
}
|
||||
|
||||
_, bound := boundPolicyIDs[policyID]
|
||||
policy["apply_status"] = policyApplyState(policy, bound)
|
||||
}
|
||||
|
||||
switch value := data.(type) {
|
||||
case map[string]any:
|
||||
normalize(value)
|
||||
if items, ok := value["items"].([]any); ok {
|
||||
for _, item := range items {
|
||||
if policy, ok := item.(map[string]any); ok {
|
||||
normalize(policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
if policy, ok := item.(map[string]any); ok {
|
||||
normalize(policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func policyIDFromResponse(policy map[string]any) (uint64, bool) {
|
||||
if policyID, ok := uint64FromJSON(policy["id"]); ok {
|
||||
return policyID, true
|
||||
}
|
||||
return uint64FromJSON(policy["policy_id"])
|
||||
}
|
||||
|
||||
func uint64FromJSON(value any) (uint64, bool) {
|
||||
switch number := value.(type) {
|
||||
case uint64:
|
||||
return number, true
|
||||
case int:
|
||||
if number >= 0 {
|
||||
return uint64(number), true
|
||||
}
|
||||
case float64:
|
||||
if number >= 0 && number < math.MaxUint64 && math.Trunc(number) == number {
|
||||
return uint64(number), true
|
||||
}
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseUint(number.String(), 10, 64)
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseUint(number, 10, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func policyApplyState(policy map[string]any, bound bool) biz.WafPolicyApplyState {
|
||||
if !bound {
|
||||
return biz.WafPolicyApplyStateSaved
|
||||
}
|
||||
if lastError, ok := policy["last_error"].(string); ok && lastError != "" {
|
||||
return biz.WafPolicyApplyStateFailed
|
||||
}
|
||||
|
||||
targetVersion, hasTarget := uint64FromJSON(policy["target_version"])
|
||||
appliedVersion, hasApplied := uint64FromJSON(policy["applied_version"])
|
||||
if hasTarget && targetVersion > 0 && hasApplied && appliedVersion >= targetVersion {
|
||||
return biz.WafPolicyApplyStateApplied
|
||||
}
|
||||
return biz.WafPolicyApplyStatePending
|
||||
}
|
||||
|
||||
func (r *wafRepo) DeletePolicy(policyID string) error {
|
||||
r.bindMu.Lock()
|
||||
defer r.bindMu.Unlock()
|
||||
|
||||
// 删除前校验该策略是否仍被网站绑定,有引用则拒绝,避免站点配置 waf_policy 指向已删策略
|
||||
pid, err := strconv.ParseUint(policyID, 10, 64)
|
||||
if err != nil {
|
||||
return &wafError{status: http.StatusBadRequest, message: r.t.Get("invalid policy id: %s", policyID)}
|
||||
}
|
||||
var count int64
|
||||
if err = r.db.Model(&biz.WafBinding{}).Where("policy_id = ?", pid).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return &wafError{
|
||||
status: http.StatusConflict,
|
||||
message: r.t.Get("the policy is in use by websites, please disable WAF for those websites first"),
|
||||
}
|
||||
}
|
||||
|
||||
_, err = r.do(resty.MethodDelete, fmt.Sprintf("/api/policies/%s", policyID), nil, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *wafRepo) ListDecisions(query map[string]string) (any, error) {
|
||||
return r.do(resty.MethodGet, "/api/decisions", query, nil)
|
||||
}
|
||||
|
||||
func (r *wafRepo) CreateDecision(req *request.WafDecisionCreate) (any, error) {
|
||||
return r.do(resty.MethodPost, "/api/decisions", nil, req)
|
||||
}
|
||||
|
||||
func (r *wafRepo) DeleteDecision(query map[string]string) error {
|
||||
// agent 用 RESTful 路径 DELETE /api/decisions/{id},面板对外仍收 ?id= 查询,这里转成路径参
|
||||
decisionID := query["id"]
|
||||
if decisionID == "" {
|
||||
return &wafError{status: http.StatusBadRequest, message: r.t.Get("missing required parameter: id")}
|
||||
}
|
||||
_, err := r.do(resty.MethodDelete, fmt.Sprintf("/api/decisions/%s", decisionID), nil, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *wafRepo) ListExclusions(policyID string) (any, error) {
|
||||
return r.do(resty.MethodGet, fmt.Sprintf("/api/policies/%s/exclusions", policyID), nil, nil)
|
||||
}
|
||||
|
||||
func (r *wafRepo) CreateExclusion(policyID string, body any) (any, error) {
|
||||
return r.do(resty.MethodPost, fmt.Sprintf("/api/policies/%s/exclusions", policyID), nil, body)
|
||||
}
|
||||
|
||||
func (r *wafRepo) DeleteExclusion(policyID string, query map[string]string) error {
|
||||
// agent 用 RESTful 路径 DELETE /api/policies/{id}/exclusions/{eid},面板对外仍收 ?id= 查询
|
||||
exclusionID := query["id"]
|
||||
if exclusionID == "" {
|
||||
return &wafError{status: http.StatusBadRequest, message: r.t.Get("missing required parameter: id")}
|
||||
}
|
||||
_, err := r.do(resty.MethodDelete, fmt.Sprintf("/api/policies/%s/exclusions/%s", policyID, exclusionID), nil, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *wafRepo) Events(query map[string]string) (any, error) {
|
||||
return r.do(resty.MethodGet, "/api/events", query, nil)
|
||||
}
|
||||
|
||||
func (r *wafRepo) Stats(query map[string]string) (any, error) {
|
||||
return r.do(resty.MethodGet, "/api/stats", query, nil)
|
||||
}
|
||||
|
||||
func (r *wafRepo) AttackMap(query map[string]string) (any, error) {
|
||||
return r.do(resty.MethodGet, "/api/attack-map", query, nil)
|
||||
}
|
||||
|
||||
// ===================== 网站绑定 + nginx 启停 =====================
|
||||
|
||||
func (r *wafRepo) ListBindings() ([]*biz.WafBinding, error) {
|
||||
bindings := make([]*biz.WafBinding, 0)
|
||||
if err := r.db.Model(&biz.WafBinding{}).Order("id DESC").Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 补充网站名称(仅显示)
|
||||
for _, binding := range bindings {
|
||||
website := new(biz.Website)
|
||||
if err := r.db.Select("name").Where("id = ?", binding.WebsiteID).First(website).Error; err == nil {
|
||||
binding.WebsiteName = website.Name
|
||||
}
|
||||
}
|
||||
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// EnableWebsite 为网站启用 WAF:写 site/011-waf.conf 并 reload
|
||||
func (r *wafRepo) EnableWebsite(req *request.WafWebsiteToggle) error {
|
||||
r.bindMu.Lock()
|
||||
defer r.bindMu.Unlock()
|
||||
|
||||
website := new(biz.Website)
|
||||
if err := r.db.Where("id = ?", req.WebsiteID).First(website).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vhost, err := r.getVhost(website)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = r.GetPolicy(strconv.FormatUint(req.PolicyID, 10)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
previousConfig := vhost.Config(wafConfigName, webservertypes.ScopeSite)
|
||||
content := fmt.Sprintf("waf on;\nwaf_policy %d;", req.PolicyID)
|
||||
if err = r.writeWafConfig(vhost, content, false); err != nil {
|
||||
return r.restoreWafConfig(vhost, previousConfig, err)
|
||||
}
|
||||
if err = r.reloadWebServer(); err != nil {
|
||||
return r.restoreWafConfig(vhost, previousConfig, err)
|
||||
}
|
||||
|
||||
binding := &biz.WafBinding{
|
||||
WebsiteID: req.WebsiteID,
|
||||
PolicyID: req.PolicyID,
|
||||
Enabled: true,
|
||||
}
|
||||
if err = r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var exists int64
|
||||
if e := tx.Model(&biz.Website{}).Where("id = ?", req.WebsiteID).Count(&exists).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if exists == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "website_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"policy_id": req.PolicyID, "enabled": true, "updated_at": time.Now(),
|
||||
}),
|
||||
}).Create(binding).Error
|
||||
}); err != nil {
|
||||
return r.restoreWafConfig(vhost, previousConfig, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableWebsite 为网站关闭 WAF:移除 site/011-waf.conf 并 reload
|
||||
func (r *wafRepo) DisableWebsite(websiteID uint) error {
|
||||
r.bindMu.Lock()
|
||||
defer r.bindMu.Unlock()
|
||||
|
||||
website := new(biz.Website)
|
||||
if err := r.db.Where("id = ?", websiteID).First(website).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vhost, err := r.getVhost(website)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
previousConfig := vhost.Config(wafConfigName, webservertypes.ScopeSite)
|
||||
if previousConfig != "" {
|
||||
if err = r.writeWafConfig(vhost, "", false); err != nil {
|
||||
return r.restoreWafConfig(vhost, previousConfig, err)
|
||||
}
|
||||
if err = r.reloadWebServer(); err != nil {
|
||||
return r.restoreWafConfig(vhost, previousConfig, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = r.db.Where("website_id = ?", websiteID).Delete(&biz.WafBinding{}).Error; err != nil {
|
||||
if previousConfig != "" {
|
||||
return r.restoreWafConfig(vhost, previousConfig, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getVhost 获取网站 vhost(与 website 仓库一致的类型分派)
|
||||
func (r *wafRepo) getVhost(website *biz.Website) (webservertypes.Vhost, error) {
|
||||
webServer, err := r.setting.Get(biz.SettingKeyWebserver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if webServer != "nginx" {
|
||||
return nil, &wafError{status: http.StatusConflict, message: r.t.Get("WAF requires nginx")}
|
||||
}
|
||||
|
||||
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
|
||||
switch website.Type {
|
||||
case biz.WebsiteTypeProxy:
|
||||
return webserver.NewProxyVhost(webserver.TypeNginx, configDir)
|
||||
case biz.WebsiteTypePHP:
|
||||
return webserver.NewPHPVhost(webserver.TypeNginx, configDir)
|
||||
case biz.WebsiteTypeStatic:
|
||||
return webserver.NewStaticVhost(webserver.TypeNginx, configDir)
|
||||
default:
|
||||
return nil, errors.New(r.t.Get("unsupported website type: %s", website.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *wafRepo) writeWafConfig(vhost webservertypes.Vhost, content string, raw bool) error {
|
||||
var err error
|
||||
if content == "" {
|
||||
err = vhost.RemoveConfig(wafConfigName, webservertypes.ScopeSite)
|
||||
} else if raw {
|
||||
err = vhost.SetRawConfig(wafConfigName, webservertypes.ScopeSite, content)
|
||||
} else {
|
||||
err = vhost.SetConfig(wafConfigName, webservertypes.ScopeSite, content)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vhost.Save()
|
||||
}
|
||||
|
||||
func (r *wafRepo) restoreWafConfig(vhost webservertypes.Vhost, content string, cause error) error {
|
||||
if err := r.writeWafConfig(vhost, content, true); err != nil {
|
||||
return fmt.Errorf("%w; restore WAF config failed: %v", cause, err)
|
||||
}
|
||||
if err := r.reloadWebServer(); err != nil {
|
||||
return fmt.Errorf("%w; restore WAF config failed: %v", cause, err)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (r *wafRepo) reloadWebServer() error {
|
||||
webServer, err := r.setting.Get(biz.SettingKeyWebserver, "unknown")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if webServer != "nginx" {
|
||||
return errors.New(r.t.Get("unsupported web server: %s", webServer))
|
||||
}
|
||||
if out, testErr := shell.Execf("nginx -t"); testErr != nil {
|
||||
r.log.Warn("nginx config test failed", slog.String("cmd", "nginx -t"), slog.Any("err", testErr))
|
||||
return fmt.Errorf("nginx config test failed: %w; output: %s", testErr, out)
|
||||
}
|
||||
if err = systemctl.Reload("nginx"); err != nil {
|
||||
return fmt.Errorf("reload nginx failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/libtnb/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
)
|
||||
|
||||
func TestNormalizePolicyStatusUsesPanelBindings(t *testing.T) {
|
||||
boundPolicyIDs := map[uint64]struct{}{
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
}
|
||||
policies := []any{
|
||||
map[string]any{"id": float64(1), "version": float64(3), "applied_version": float64(3)},
|
||||
map[string]any{"id": float64(2), "version": float64(3), "applied_version": float64(2)},
|
||||
map[string]any{"id": float64(3), "version": float64(3), "applied_version": float64(3)},
|
||||
map[string]any{"id": float64(4), "version": float64(3), "applied_version": float64(2), "last_error": "load failed"},
|
||||
map[string]any{"id": float64(5), "version": float64(3)},
|
||||
}
|
||||
|
||||
normalizePolicyStatus(policies, boundPolicyIDs)
|
||||
|
||||
assert.Equal(t, biz.WafPolicyApplyStateSaved, policies[0].(map[string]any)["apply_status"])
|
||||
assert.Equal(t, biz.WafPolicyApplyStatePending, policies[1].(map[string]any)["apply_status"])
|
||||
assert.Equal(t, biz.WafPolicyApplyStateApplied, policies[2].(map[string]any)["apply_status"])
|
||||
assert.Equal(t, biz.WafPolicyApplyStateFailed, policies[3].(map[string]any)["apply_status"])
|
||||
assert.Equal(t, biz.WafPolicyApplyStatePending, policies[4].(map[string]any)["apply_status"])
|
||||
}
|
||||
|
||||
func TestNormalizePolicyStatusMarksUnboundPolicySavedDespiteStaleAgentState(t *testing.T) {
|
||||
policy := map[string]any{
|
||||
"policy_id": float64(7),
|
||||
"target_version": float64(4),
|
||||
"applied_version": float64(4),
|
||||
"last_error": "old error",
|
||||
}
|
||||
|
||||
normalizePolicyStatus(policy, nil)
|
||||
|
||||
require.Equal(t, biz.WafPolicyApplyStateSaved, policy["apply_status"])
|
||||
}
|
||||
|
||||
func TestWithPolicyApplyStateUsesEnabledBindings(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+filepath.Join(t.TempDir(), "waf.db")), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&biz.WafBinding{}))
|
||||
require.NoError(t, db.Create([]*biz.WafBinding{
|
||||
{WebsiteID: 1, PolicyID: 11, Enabled: true},
|
||||
{WebsiteID: 2, PolicyID: 12, Enabled: false},
|
||||
}).Error)
|
||||
|
||||
policies := []any{
|
||||
map[string]any{"id": float64(11), "version": float64(2), "applied_version": float64(2)},
|
||||
map[string]any{"id": float64(12), "version": float64(2), "applied_version": float64(2)},
|
||||
}
|
||||
result, err := (&wafRepo{db: db}).withPolicyApplyState(policies)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalized := result.([]any)
|
||||
assert.Equal(t, biz.WafPolicyApplyStateApplied, normalized[0].(map[string]any)["apply_status"])
|
||||
assert.Equal(t, biz.WafPolicyApplyStateSaved, normalized[1].(map[string]any)["apply_status"])
|
||||
}
|
||||
@@ -803,7 +803,12 @@ func (r *websiteRepo) RemoveFiles(name string, removePath bool) error {
|
||||
}
|
||||
|
||||
func (r *websiteRepo) Delete(website *biz.Website) error {
|
||||
return r.db.Delete(website).Error
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("website_id = ?", website.ID).Delete(&biz.WafBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(website).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *websiteRepo) UpdateRemark(id uint, remark string) error {
|
||||
|
||||
@@ -122,6 +122,15 @@ func init() {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
Migrations = append(Migrations, &gormigrate.Migration{
|
||||
ID: "20260610-add-waf",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&biz.WafBinding{})
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable(&biz.WafBinding{})
|
||||
},
|
||||
})
|
||||
Migrations = append(Migrations, &gormigrate.Migration{
|
||||
ID: "20260720-add-tamper-rules",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package request
|
||||
|
||||
// WafWebsiteToggle 网站启用 WAF
|
||||
type WafWebsiteToggle struct {
|
||||
WebsiteID uint `form:"website_id" json:"website_id" validate:"required && exists:websites,id"`
|
||||
PolicyID uint64 `form:"policy_id" json:"policy_id" validate:"required"`
|
||||
}
|
||||
|
||||
// WafDecisionCreate 手工创建动态决策,Scope 由 Value 推导。
|
||||
type WafDecisionCreate struct {
|
||||
Type string `json:"type" validate:"required && in:ban,allow,captcha"`
|
||||
Scope string `json:"scope"`
|
||||
Value string `json:"value" validate:"required"`
|
||||
Until int64 `json:"until" validate:"min:0"`
|
||||
}
|
||||
@@ -73,6 +73,7 @@ type Services struct {
|
||||
WebHook *service.WebHookService
|
||||
Website *service.WebsiteService
|
||||
WebsiteStat *service.WebsiteStatService
|
||||
Waf *service.WafService
|
||||
Ws *service.WsService
|
||||
}
|
||||
|
||||
@@ -120,6 +121,7 @@ func NewEndpoints(s *Services) []Endpoints {
|
||||
ToolboxLogRoutes(s.ToolboxLog),
|
||||
ToolboxMigrationRoutes(s.ToolboxMigration),
|
||||
TamperRoutes(s.Tamper),
|
||||
WafRoutes(s.Waf),
|
||||
WsRoutes(s.ToolboxMigration, s.Ws),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/request"
|
||||
"github.com/acepanel/panel/v3/internal/service"
|
||||
)
|
||||
|
||||
// WafRoutes WAF 管理路由
|
||||
func WafRoutes(waf *service.WafService) Endpoints {
|
||||
return Endpoints{
|
||||
// 策略透传
|
||||
{Method: http.MethodGet, Path: "/api/waf/policies", Handler: waf.ListPolicies, Summary: "策略列表", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodPost, Path: "/api/waf/policies", Handler: waf.CreatePolicy, Summary: "创建策略", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodGet, Path: "/api/waf/policies/{policy_id}", Handler: waf.GetPolicy, Summary: "获取策略", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodGet, Path: "/api/waf/policies/{policy_id}/status", Handler: waf.GetPolicyStatus, Summary: "获取策略应用状态", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodPut, Path: "/api/waf/policies/{policy_id}", Handler: waf.UpdatePolicy, Summary: "更新策略", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodDelete, Path: "/api/waf/policies/{policy_id}", Handler: waf.DeletePolicy, Summary: "删除策略", Tags: []string{"WAF"}},
|
||||
// 误报加白透传
|
||||
{Method: http.MethodGet, Path: "/api/waf/policies/{policy_id}/exclusions", Handler: waf.ListExclusions, Summary: "加白列表", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodPost, Path: "/api/waf/policies/{policy_id}/exclusions", Handler: waf.CreateExclusion, Summary: "创建加白", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodDelete, Path: "/api/waf/policies/{policy_id}/exclusions", Handler: waf.DeleteExclusion, Summary: "删除加白", Tags: []string{"WAF"}},
|
||||
// 决策透传(黑白名单/拉黑)
|
||||
{Method: http.MethodGet, Path: "/api/waf/decisions", Handler: waf.ListDecisions, Summary: "决策列表", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodPost, Path: "/api/waf/decisions", Handler: waf.CreateDecision, Summary: "创建决策", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodDelete, Path: "/api/waf/decisions", Handler: waf.DeleteDecision, Summary: "删除决策", Tags: []string{"WAF"}},
|
||||
// 报表透传
|
||||
{Method: http.MethodGet, Path: "/api/waf/events", Handler: waf.Events, Summary: "攻击事件", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodGet, Path: "/api/waf/stats", Handler: waf.Stats, Summary: "统计", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodGet, Path: "/api/waf/attack-map", Handler: waf.AttackMap, Summary: "攻击地图", Tags: []string{"WAF"}},
|
||||
// 网站绑定 + 启停
|
||||
{Method: http.MethodGet, Path: "/api/waf/bindings", Handler: waf.ListBindings, Summary: "绑定列表", Tags: []string{"WAF"}},
|
||||
{Method: http.MethodPost, Path: "/api/waf/website/enable", Handler: waf.EnableWebsite, Summary: "启用网站 WAF", Tags: []string{"WAF"},
|
||||
Request: request.WafWebsiteToggle{}},
|
||||
{Method: http.MethodPost, Path: "/api/waf/website/{id}/disable", Handler: waf.DisableWebsite, Summary: "停用网站 WAF", Tags: []string{"WAF"}},
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,5 @@ var ProviderSet = wire.NewSet(
|
||||
NewToolboxNetworkService, NewToolboxSystemService, NewToolboxBenchmarkService,
|
||||
NewToolboxSSHService, NewToolboxDiskService, NewToolboxLogService,
|
||||
NewToolboxMigrationService, NewWsService,
|
||||
NewWafService,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
"github.com/acepanel/panel/v3/internal/request"
|
||||
)
|
||||
|
||||
type WafService struct {
|
||||
wafRepo *biz.WafUsecase
|
||||
t *gotext.Locale
|
||||
}
|
||||
|
||||
func NewWafService(wafUsecase *biz.WafUsecase, t *gotext.Locale) (*WafService, error) {
|
||||
return &WafService{
|
||||
wafRepo: wafUsecase,
|
||||
t: t,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ===================== 策略透传 =====================
|
||||
|
||||
func (s *WafService) ListPolicies(w http.ResponseWriter, _ *http.Request) {
|
||||
data, err := s.wafRepo.ListPolicies()
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) GetPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.wafRepo.GetPolicy(chi.URLParam(r, "policy_id"))
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) GetPolicyStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.wafRepo.GetPolicyStatus(chi.URLParam(r, "policy_id"))
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) CreatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodeBody(r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := s.wafRepo.CreatePolicy(body)
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodeBody(r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := s.wafRepo.UpdatePolicy(chi.URLParam(r, "policy_id"), body)
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) DeletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.wafRepo.DeletePolicy(chi.URLParam(r, "policy_id")); err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// ===================== 决策透传 =====================
|
||||
|
||||
func (s *WafService) ListDecisions(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.wafRepo.ListDecisions(flattenQuery(r))
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) CreateDecision(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.WafDecisionCreate](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
value, scope, ok := normalizeDecisionValue(req.Value)
|
||||
if !ok {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("decision value must be a valid IP address or CIDR"))
|
||||
return
|
||||
}
|
||||
if req.Scope != "" && req.Scope != scope {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("decision scope does not match value; expected %s", scope))
|
||||
return
|
||||
}
|
||||
req.Value = value
|
||||
req.Scope = scope
|
||||
|
||||
data, err := s.wafRepo.CreateDecision(req)
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func normalizeDecisionValue(value string) (string, string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if addr, err := netip.ParseAddr(value); err == nil && addr.Zone() == "" {
|
||||
return addr.String(), "ip", true
|
||||
}
|
||||
if prefix, err := netip.ParsePrefix(value); err == nil {
|
||||
return prefix.Masked().String(), "range", true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func (s *WafService) DeleteDecision(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.wafRepo.DeleteDecision(flattenQuery(r)); err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// ===================== 误报加白透传 =====================
|
||||
|
||||
func (s *WafService) ListExclusions(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.wafRepo.ListExclusions(chi.URLParam(r, "policy_id"))
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) CreateExclusion(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodeBody(r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := s.wafRepo.CreateExclusion(chi.URLParam(r, "policy_id"), body)
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) DeleteExclusion(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.wafRepo.DeleteExclusion(chi.URLParam(r, "policy_id"), flattenQuery(r)); err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// ===================== 报表透传 =====================
|
||||
|
||||
func (s *WafService) Events(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.wafRepo.Events(flattenQuery(r))
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.wafRepo.Stats(flattenQuery(r))
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
func (s *WafService) AttackMap(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.wafRepo.AttackMap(flattenQuery(r))
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, data)
|
||||
}
|
||||
|
||||
// ===================== 网站绑定 + 启停 =====================
|
||||
|
||||
func (s *WafService) ListBindings(w http.ResponseWriter, _ *http.Request) {
|
||||
bindings, err := s.wafRepo.ListBindings()
|
||||
if err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, bindings)
|
||||
}
|
||||
|
||||
// EnableWebsite 网站启用 WAF
|
||||
func (s *WafService) EnableWebsite(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.WafWebsiteToggle](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.wafRepo.EnableWebsite(req); err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// DisableWebsite 网站关闭 WAF
|
||||
func (s *WafService) DisableWebsite(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.wafRepo.DisableWebsite(req.ID); err != nil {
|
||||
writeWafError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// decodeBody 将请求体原样解析为 any(透传 JSON,对 agent 字段新增保持健壮)
|
||||
func decodeBody(r *http.Request) (any, error) {
|
||||
if r.Body == nil || r.ContentLength == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var body any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// flattenQuery 把 URL 查询参数展平为 map[string]string(透传给 agent)
|
||||
func flattenQuery(r *http.Request) map[string]string {
|
||||
query := make(map[string]string)
|
||||
for key, values := range r.URL.Query() {
|
||||
if len(values) > 0 {
|
||||
query[key] = values[0]
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func writeWafError(w http.ResponseWriter, err error) {
|
||||
code := http.StatusInternalServerError
|
||||
var statusError interface{ StatusCode() int }
|
||||
if errors.As(err, &statusError) {
|
||||
code = statusError.StatusCode()
|
||||
}
|
||||
Error(w, code, "%v", err)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { http } from '@/utils'
|
||||
|
||||
export type WafPolicyApplyState = 'saved' | 'pending' | 'applied' | 'failed'
|
||||
|
||||
export interface WafPolicy {
|
||||
id: number
|
||||
apply_status?: WafPolicyApplyState
|
||||
version?: number | null
|
||||
target_version?: number | null
|
||||
applied_version?: number | null
|
||||
last_error?: string | null
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface WafPolicyStatus {
|
||||
policy_id: number
|
||||
apply_status: WafPolicyApplyState
|
||||
target_version: number
|
||||
applied_version: number
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface WafDecisionInput {
|
||||
type: 'ban' | 'allow' | 'captcha'
|
||||
value: string
|
||||
until: number
|
||||
}
|
||||
|
||||
export const policyApplyState = (policy: WafPolicy): WafPolicyApplyState =>
|
||||
policy.apply_status ?? 'saved'
|
||||
|
||||
export default {
|
||||
// ===================== 策略 =====================
|
||||
// 策略列表
|
||||
policies: (): any => http.Get('/waf/policies'),
|
||||
// 获取策略
|
||||
policy: (policyId: number | string): any => http.Get(`/waf/policies/${policyId}`),
|
||||
// 获取策略应用状态
|
||||
policyStatus: (policyId: number | string): any => http.Get(`/waf/policies/${policyId}/status`),
|
||||
// 创建策略
|
||||
createPolicy: (data: any): any => http.Post('/waf/policies', data),
|
||||
// 更新策略
|
||||
updatePolicy: (policyId: number | string, data: any): any =>
|
||||
http.Put(`/waf/policies/${policyId}`, data),
|
||||
// 删除策略
|
||||
deletePolicy: (policyId: number | string): any => http.Delete(`/waf/policies/${policyId}`),
|
||||
|
||||
// ===================== 误报加白 =====================
|
||||
// 加白列表
|
||||
exclusions: (policyId: number | string): any => http.Get(`/waf/policies/${policyId}/exclusions`),
|
||||
// 创建加白
|
||||
createExclusion: (policyId: number | string, data: any): any =>
|
||||
http.Post(`/waf/policies/${policyId}/exclusions`, data),
|
||||
// 删除加白
|
||||
deleteExclusion: (policyId: number | string, exclusionId: number): any =>
|
||||
http.Delete(`/waf/policies/${policyId}/exclusions`, undefined, {
|
||||
params: { id: exclusionId },
|
||||
}),
|
||||
|
||||
// ===================== 决策黑白名单 =====================
|
||||
// 决策列表
|
||||
decisions: (page: number, limit: number): any =>
|
||||
http.Get('/waf/decisions', { params: { page, limit } }),
|
||||
// 创建/更新决策
|
||||
createDecision: (data: WafDecisionInput): any => http.Post('/waf/decisions', data),
|
||||
// 删除决策
|
||||
deleteDecision: (decisionId: number): any =>
|
||||
http.Delete('/waf/decisions', undefined, { params: { id: decisionId } }),
|
||||
|
||||
// ===================== 报表 =====================
|
||||
// 事件日志
|
||||
events: (params: any): any => http.Get('/waf/events', { params }),
|
||||
// 统计总览
|
||||
stats: (since?: number): any => http.Get('/waf/stats', { params: { since } }),
|
||||
// 攻击地图
|
||||
attackMap: (since?: number): any => http.Get('/waf/attack-map', { params: { since } }),
|
||||
|
||||
// ===================== 网站绑定 + 启停 =====================
|
||||
// 绑定列表
|
||||
bindings: (): any => http.Get('/waf/bindings'),
|
||||
// 网站启用 WAF
|
||||
enableWebsite: (data: any): any => http.Post('/waf/website/enable', data),
|
||||
// 网站关闭 WAF
|
||||
disableWebsite: (websiteId: number): any => http.Post(`/waf/website/${websiteId}/disable`),
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { MapChart } from 'echarts/charts'
|
||||
import { GeoComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
|
||||
import { registerMap, use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf from '@/api/panel/waf'
|
||||
import { useThemeStore } from '@/stores'
|
||||
import { codeToGeoName, codeToName } from '@/views/website/stats/country-name-map'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
use([CanvasRenderer, MapChart, TooltipComponent, VisualMapComponent, GeoComponent])
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<any[]>([])
|
||||
const mapReady = ref(false)
|
||||
const rangeDays = ref(7)
|
||||
|
||||
const rangeOptions = computed(() => [
|
||||
{ label: $gettext('Last 24 hours'), value: 1 },
|
||||
{ label: $gettext('Last 7 days'), value: 7 },
|
||||
{ label: $gettext('Last 30 days'), value: 30 },
|
||||
])
|
||||
|
||||
// 懒加载世界地图 GeoJSON
|
||||
const loadMap = async () => {
|
||||
if (mapReady.value) return
|
||||
try {
|
||||
const resp = await fetch('/data/world.json')
|
||||
const geoJson = await resp.json()
|
||||
registerMap('world', geoJson as any)
|
||||
mapReady.value = true
|
||||
} catch (e) {
|
||||
console.warn('Failed to load world map:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const since = computed(() => Math.floor(Date.now() / 1000) - rangeDays.value * 86400)
|
||||
|
||||
const loadData = () => {
|
||||
loading.value = true
|
||||
useRequest(waf.attackMap(since.value))
|
||||
.onSuccess(({ data }: any) => {
|
||||
// agent /api/attack-map 返回 { points, top_country, generated_at },top_country 为 [{key,count}]
|
||||
items.value = data?.top_country || []
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(rangeDays, () => loadData())
|
||||
|
||||
onMounted(() => {
|
||||
loadMap()
|
||||
loadData()
|
||||
})
|
||||
|
||||
const toGeoName = (code: string): string => codeToGeoName[code] || code
|
||||
|
||||
const mapOption = computed<EChartsOption>(() => {
|
||||
const isDark = themeStore.darkMode
|
||||
const data = items.value
|
||||
.filter((i: any) => i.key)
|
||||
.map((i: any) => ({
|
||||
name: toGeoName(i.key),
|
||||
value: i.count,
|
||||
originalName: codeToName[i.key] || i.key,
|
||||
}))
|
||||
const maxValue = data.reduce((max: number, d: any) => Math.max(max, d.value), 0)
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
if (!params.data?.value) return `${params.name}: ${$gettext('No data')}`
|
||||
const name = params.data.originalName || params.name
|
||||
return `${name}<br/>${$gettext('Attacks')}: ${params.data.value.toLocaleString()}`
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: maxValue || 100,
|
||||
left: 'left',
|
||||
bottom: 20,
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: isDark
|
||||
? ['#3a1a1a', '#6a2a2a', '#ca3a3a', '#ea5a5a', '#ff8a8a']
|
||||
: ['#ffe0e0', '#f0a0a0', '#e06060', '#d02020', '#c00000'],
|
||||
},
|
||||
textStyle: { color: isDark ? '#ccc' : '#333' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'map',
|
||||
map: 'world',
|
||||
layoutCenter: ['50%', '50%'],
|
||||
layoutSize: '180%',
|
||||
roam: true,
|
||||
emphasis: {
|
||||
label: { show: true },
|
||||
itemStyle: { areaColor: isDark ? '#aa4a4a' : '#cc3333' },
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: isDark ? '#2a2a3a' : '#e9ecef',
|
||||
borderColor: isDark ? '#444' : '#aaa',
|
||||
},
|
||||
data,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const tableColumns: any = [
|
||||
{
|
||||
title: $gettext('Country'),
|
||||
key: 'key',
|
||||
render: (row: any) => codeToName[row.key] || row.key || $gettext('Unknown'),
|
||||
},
|
||||
{
|
||||
title: $gettext('Attacks'),
|
||||
key: 'count',
|
||||
sorter: (a: any, b: any) => a.count - b.count,
|
||||
},
|
||||
]
|
||||
|
||||
// 按攻击数倒序展示
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a: any, b: any) => b.count - a.count)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex align="center">
|
||||
<n-select v-model:value="rangeDays" :options="rangeOptions" class="w-40" />
|
||||
<n-button @click="loadData">{{ $gettext('Refresh') }}</n-button>
|
||||
</n-flex>
|
||||
<n-spin :show="loading">
|
||||
<n-card v-if="mapReady && items.length > 0" :bordered="false" :title="$gettext('Attack Map')">
|
||||
<v-chart class="h-450px" :option="mapOption" autoresize />
|
||||
</n-card>
|
||||
<n-empty
|
||||
v-else-if="!loading && items.length === 0"
|
||||
:description="$gettext('No attack data')"
|
||||
class="my-10"
|
||||
/>
|
||||
<n-card :bordered="false" :title="$gettext('Top Attack Sources')" class="mt-4">
|
||||
<n-data-table :columns="tableColumns" :data="sortedItems" size="small" :bordered="false" />
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf from '@/api/panel/waf'
|
||||
import website from '@/api/panel/website'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
|
||||
const loading = ref(false)
|
||||
const websites = ref<any[]>([])
|
||||
const policies = ref<any[]>([])
|
||||
|
||||
const websiteId = ref<number | null>(null)
|
||||
const policyId = ref<number | null>(null)
|
||||
|
||||
const websiteOptions = computed(() =>
|
||||
websites.value.map((w: any) => ({ label: w.name, value: w.id }))
|
||||
)
|
||||
const policyOptions = computed(() =>
|
||||
policies.value.map((p: any) => ({ label: `${p.name} (#${p.id})`, value: p.id }))
|
||||
)
|
||||
|
||||
watch(show, (v) => {
|
||||
if (v) {
|
||||
websiteId.value = null
|
||||
policyId.value = null
|
||||
// 加载网站列表
|
||||
useRequest(website.list('all', 1, 10000)).onSuccess(({ data }: any) => {
|
||||
websites.value = data?.items || []
|
||||
})
|
||||
// 加载策略列表
|
||||
useRequest(waf.policies()).onSuccess(({ data }: any) => {
|
||||
policies.value = Array.isArray(data) ? data : data?.items || []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!websiteId.value) {
|
||||
window.$message.error($gettext('Please select a website'))
|
||||
return
|
||||
}
|
||||
if (!policyId.value) {
|
||||
window.$message.error($gettext('Please select a policy'))
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
useRequest(
|
||||
waf.enableWebsite({
|
||||
website_id: websiteId.value,
|
||||
policy_id: policyId.value,
|
||||
})
|
||||
)
|
||||
.onSuccess(() => {
|
||||
show.value = false
|
||||
window.$message.success($gettext('Enabled successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="$gettext('Enable WAF for Website')"
|
||||
style="width: 50vw; max-width: 560px"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="show = false"
|
||||
>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item :label="$gettext('Website')">
|
||||
<n-select
|
||||
v-model:value="websiteId"
|
||||
:options="websiteOptions"
|
||||
filterable
|
||||
:placeholder="$gettext('Select a website')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Policy')">
|
||||
<n-select
|
||||
v-model:value="policyId"
|
||||
:options="policyOptions"
|
||||
:placeholder="$gettext('Select a policy')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" :bordered="false" class="mb-3">
|
||||
{{
|
||||
$gettext(
|
||||
'This writes the WAF directive into the website nginx config and reloads the web server.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-button type="info" block :loading="loading" :disabled="loading" @click="handleSubmit">
|
||||
{{ $gettext('Submit') }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf from '@/api/panel/waf'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
import { formatDateTime } from '@/utils'
|
||||
import BindingModal from '@/views/waf/BindingModal.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmAction } = useConfirm()
|
||||
|
||||
const loading = ref(false)
|
||||
const bindings = ref<any[]>([])
|
||||
const modalShow = ref(false)
|
||||
|
||||
const loadBindings = () => {
|
||||
loading.value = true
|
||||
useRequest(waf.bindings())
|
||||
.onSuccess(({ data }: any) => {
|
||||
bindings.value = Array.isArray(data) ? data : data?.items || []
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(modalShow, (v) => {
|
||||
if (!v) loadBindings()
|
||||
})
|
||||
|
||||
const columns: any = [
|
||||
{
|
||||
title: $gettext('Website'),
|
||||
key: 'website_name',
|
||||
minWidth: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row: any) => row.website_name || `#${row.website_id}`,
|
||||
},
|
||||
{
|
||||
title: $gettext('Policy ID'),
|
||||
key: 'policy_id',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: $gettext('Status'),
|
||||
key: 'enabled',
|
||||
width: 110,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.enabled ? 'success' : 'default' },
|
||||
{ default: () => (row.enabled ? $gettext('Enabled') : $gettext('Disabled')) }
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Created At'),
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (row: any) => formatDateTime(row.created_at),
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
align: 'center',
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'warning',
|
||||
title: $gettext('Disable WAF'),
|
||||
content: $gettext('Are you sure you want to disable WAF for this website?'),
|
||||
})
|
||||
if (ok) handleDisable(row.website_id)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Disable') }
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const handleDisable = (websiteId: number) => {
|
||||
useRequest(waf.disableWebsite(websiteId)).onSuccess(() => {
|
||||
loadBindings()
|
||||
window.$message.success($gettext('Disabled successfully'))
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBindings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex items-center>
|
||||
<n-button type="primary" @click="modalShow = true">
|
||||
{{ $gettext('Enable WAF for Website') }}
|
||||
</n-button>
|
||||
<n-button @click="loadBindings">{{ $gettext('Refresh') }}</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:scroll-x="800"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="bindings"
|
||||
:row-key="(row: any) => row.id"
|
||||
/>
|
||||
</n-flex>
|
||||
<binding-modal v-model:show="modalShow" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf, { type WafDecisionInput } from '@/api/panel/waf'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
type DecisionForm = {
|
||||
type: WafDecisionInput['type']
|
||||
value: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
const defaultModel = (): DecisionForm => ({
|
||||
type: 'ban',
|
||||
value: '',
|
||||
duration: 0, // 拉黑时长(小时),0=永久;提交时换算为 until
|
||||
})
|
||||
|
||||
const model = ref(defaultModel())
|
||||
|
||||
watch(show, (v) => {
|
||||
if (v) model.value = defaultModel()
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ label: $gettext('Ban'), value: 'ban' },
|
||||
{ label: $gettext('Allow'), value: 'allow' },
|
||||
{ label: $gettext('Captcha'), value: 'captcha' },
|
||||
])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!model.value.value) {
|
||||
window.$message.error($gettext('Please enter an IP or CIDR'))
|
||||
return
|
||||
}
|
||||
// duration 小时换算为绝对过期时间戳(秒),0 表示永久
|
||||
const until =
|
||||
model.value.duration > 0 ? Math.floor(Date.now() / 1000) + model.value.duration * 3600 : 0
|
||||
loading.value = true
|
||||
useRequest(
|
||||
waf.createDecision({
|
||||
type: model.value.type,
|
||||
value: model.value.value,
|
||||
until,
|
||||
}),
|
||||
)
|
||||
.onSuccess(() => {
|
||||
show.value = false
|
||||
window.$message.success($gettext('Added successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="$gettext('Add Allow/Deny Entry')"
|
||||
style="width: 50vw; max-width: 560px"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="show = false"
|
||||
>
|
||||
<n-form :model="model" label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="16">
|
||||
<n-form-item-gi :span="24" :label="$gettext('Type')">
|
||||
<n-select v-model:value="model.type" :options="typeOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="24" :label="$gettext('IP / CIDR')">
|
||||
<n-input
|
||||
v-model:value="model.value"
|
||||
:placeholder="$gettext('e.g., 1.2.3.4 or 1.2.3.0/24')"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="model.type !== 'allow'"
|
||||
:span="24"
|
||||
:label="$gettext('Duration in hours (0 = permanent)')"
|
||||
>
|
||||
<n-input-number v-model:value="model.duration" :min="0" w-full />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
<n-button type="info" block :loading="loading" :disabled="loading" @click="handleSubmit">
|
||||
{{ $gettext('Submit') }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf from '@/api/panel/waf'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
import { formatDateTime } from '@/utils'
|
||||
import DecisionModal from '@/views/waf/DecisionModal.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete } = useConfirm()
|
||||
|
||||
const modalShow = ref(false)
|
||||
|
||||
type TagType = 'default' | 'error' | 'info' | 'success' | 'warning' | 'primary'
|
||||
const typeTag = (type: string): { type: TagType; label: string } => {
|
||||
switch (type) {
|
||||
case 'ban':
|
||||
return { type: 'error', label: $gettext('Ban') }
|
||||
case 'allow':
|
||||
return { type: 'success', label: $gettext('Allow') }
|
||||
case 'captcha':
|
||||
return { type: 'warning', label: $gettext('Captcha') }
|
||||
default:
|
||||
return { type: 'default', label: type }
|
||||
}
|
||||
}
|
||||
|
||||
const columns: any = [
|
||||
{ title: 'ID', key: 'id', width: 80 },
|
||||
{
|
||||
title: $gettext('Type'),
|
||||
key: 'type',
|
||||
width: 110,
|
||||
render(row: any) {
|
||||
const t = typeTag(row.type)
|
||||
return h(NTag, { type: t.type }, { default: () => t.label })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Scope'),
|
||||
key: 'scope',
|
||||
width: 100,
|
||||
render: (row: any) =>
|
||||
String(row.value).includes('/') ? $gettext('Range') : $gettext('Single IP'),
|
||||
},
|
||||
{
|
||||
title: $gettext('Value'),
|
||||
key: 'value',
|
||||
minWidth: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Origin'),
|
||||
key: 'origin',
|
||||
width: 110,
|
||||
render: (row: any) => h(NTag, { size: 'small' }, { default: () => row.origin || '-' }),
|
||||
},
|
||||
{
|
||||
title: $gettext('Expires At'),
|
||||
key: 'until',
|
||||
width: 180,
|
||||
render: (row: any) =>
|
||||
row.until && row.until > 0 ? formatDateTime(new Date(row.until * 1000)) : $gettext('Never'),
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
content: $gettext('Are you sure you want to delete this entry?'),
|
||||
})
|
||||
if (ok) handleDelete(row.id)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Delete') },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { loading, data, page, total, pageSize, refresh } = usePagination(
|
||||
(page, pageSize) => waf.decisions(page, pageSize),
|
||||
{
|
||||
initialData: { total: 0, items: [] },
|
||||
initialPageSize: 20,
|
||||
total: (res: any) => res.total,
|
||||
data: (res: any) => res.items,
|
||||
},
|
||||
)
|
||||
|
||||
watch(modalShow, (v) => {
|
||||
if (!v) refresh()
|
||||
})
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
useRequest(waf.deleteDecision(id)).onSuccess(() => {
|
||||
refresh()
|
||||
window.$message.success($gettext('Deleted successfully'))
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex items-center>
|
||||
<n-button type="primary" @click="modalShow = true">
|
||||
{{ $gettext('Add Entry') }}
|
||||
</n-button>
|
||||
<n-button @click="refresh">{{ $gettext('Refresh') }}</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
striped
|
||||
remote
|
||||
:scroll-x="900"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: any) => row.id"
|
||||
:pagination="{
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
itemCount: total,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200],
|
||||
}"
|
||||
/>
|
||||
</n-flex>
|
||||
<decision-modal v-model:show="modalShow" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf from '@/api/panel/waf'
|
||||
import { formatDateTime } from '@/utils'
|
||||
import ExclusionModal from '@/views/waf/ExclusionModal.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 过滤条件
|
||||
const filterClientIP = ref('')
|
||||
const filterJA4 = ref('')
|
||||
const filterAction = ref<string | null>(null)
|
||||
const filterPolicyId = ref<number | null>(null)
|
||||
|
||||
const exclusionShow = ref(false)
|
||||
const exclusionEvent = ref<any>(null)
|
||||
|
||||
const actionFilterOptions = computed(() => [
|
||||
{ label: $gettext('All'), value: '' },
|
||||
{ label: $gettext('Block'), value: 'block' },
|
||||
{ label: $gettext('Challenge'), value: 'challenge' },
|
||||
{ label: $gettext('Log'), value: 'log' },
|
||||
{ label: $gettext('Ban'), value: 'ban' },
|
||||
])
|
||||
|
||||
const actionTag = (action: string) => {
|
||||
switch (action) {
|
||||
case 'block':
|
||||
case 'ban':
|
||||
return 'error'
|
||||
case 'challenge':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const columns: any = [
|
||||
{
|
||||
title: $gettext('Time'),
|
||||
key: 'ts',
|
||||
width: 170,
|
||||
render: (row: any) => (row.ts ? formatDateTime(new Date(row.ts * 1000)) : '-'),
|
||||
},
|
||||
{
|
||||
title: $gettext('Client IP'),
|
||||
key: 'client_ip',
|
||||
width: 140,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('JA4'),
|
||||
key: 'ja4',
|
||||
width: 280,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row: any) => row.ja4 || '-',
|
||||
},
|
||||
{
|
||||
title: $gettext('Action'),
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (row: any) =>
|
||||
h(NTag, { type: actionTag(row.action), size: 'small' }, { default: () => row.action || '-' }),
|
||||
},
|
||||
{
|
||||
title: $gettext('Severity'),
|
||||
key: 'severity',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: $gettext('Host'),
|
||||
key: 'host',
|
||||
minWidth: 140,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Method'),
|
||||
key: 'method',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: 'URI',
|
||||
key: 'uri',
|
||||
minWidth: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Rule'),
|
||||
key: 'rule',
|
||||
minWidth: 140,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Country'),
|
||||
key: 'country',
|
||||
width: 90,
|
||||
render: (row: any) => row.country || '-',
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
fixed: 'right',
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
secondary: true,
|
||||
onClick: () => {
|
||||
exclusionEvent.value = row
|
||||
exclusionShow.value = true
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Add to Allowlist') }
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { loading, data, page, total, pageSize, refresh } = usePagination(
|
||||
(page, pageSize) =>
|
||||
waf.events({
|
||||
page,
|
||||
limit: pageSize,
|
||||
client_ip: filterClientIP.value || undefined,
|
||||
ja4: filterJA4.value || undefined,
|
||||
action: filterAction.value || undefined,
|
||||
policy_id: filterPolicyId.value || undefined,
|
||||
}),
|
||||
{
|
||||
initialData: { total: 0, items: [] },
|
||||
initialPageSize: 20,
|
||||
total: (res: any) => res.total,
|
||||
data: (res: any) => res.items,
|
||||
}
|
||||
)
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
refresh()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex align="center" :wrap="true">
|
||||
<n-input
|
||||
v-model:value="filterClientIP"
|
||||
:placeholder="$gettext('Client IP')"
|
||||
clearable
|
||||
class="w-48"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="filterJA4"
|
||||
:placeholder="$gettext('JA4')"
|
||||
clearable
|
||||
class="w-72"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="filterAction"
|
||||
:options="actionFilterOptions"
|
||||
:placeholder="$gettext('Action')"
|
||||
clearable
|
||||
class="w-40"
|
||||
/>
|
||||
<n-input-number
|
||||
v-model:value="filterPolicyId"
|
||||
:placeholder="$gettext('Policy ID')"
|
||||
:min="1"
|
||||
class="w-40"
|
||||
/>
|
||||
<n-button type="primary" @click="handleSearch">{{ $gettext('Search') }}</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
striped
|
||||
remote
|
||||
:scroll-x="1680"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: any) => row.id"
|
||||
:pagination="{
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
itemCount: total,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200],
|
||||
}"
|
||||
/>
|
||||
</n-flex>
|
||||
<exclusion-modal v-model:show="exclusionShow" :event="exclusionEvent" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf from '@/api/panel/waf'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const props = defineProps<{ event: any }>()
|
||||
|
||||
const loading = ref(false)
|
||||
const policies = ref<any[]>([])
|
||||
const policyId = ref<number | null>(null)
|
||||
|
||||
const defaultModel = () => ({
|
||||
enabled: true,
|
||||
detector: '',
|
||||
target: '',
|
||||
path_prefix: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const model = ref(defaultModel())
|
||||
|
||||
const policyOptions = computed(() =>
|
||||
policies.value.map((p: any) => ({ label: `${p.name} (#${p.id})`, value: p.id })),
|
||||
)
|
||||
|
||||
const detectorOptions = computed(() => [
|
||||
{ label: $gettext('Path Traversal'), value: 'path_traversal' },
|
||||
{ label: $gettext('SQL Injection'), value: 'sqli' },
|
||||
{ label: $gettext('Command Injection'), value: 'command_injection' },
|
||||
{ label: $gettext('Cross-Site Scripting'), value: 'xss' },
|
||||
{ label: $gettext('Server-Side Request Forgery'), value: 'ssrf' },
|
||||
{ label: $gettext('Code Injection'), value: 'code_injection' },
|
||||
{ label: $gettext('Unsafe Deserialization'), value: 'unsafe_deserialization' },
|
||||
{ label: $gettext('XML Injection'), value: 'xml_injection' },
|
||||
{ label: $gettext('NoSQL Injection'), value: 'nosql_injection' },
|
||||
{ label: $gettext('Protocol Injection'), value: 'protocol_injection' },
|
||||
{ label: $gettext('Response Data Leakage'), value: 'response_leakage' },
|
||||
])
|
||||
|
||||
const targetOptions = computed(() => [
|
||||
{ label: $gettext('All Inputs'), value: '' },
|
||||
{ label: $gettext('Path'), value: 'path' },
|
||||
{ label: $gettext('Query'), value: 'query' },
|
||||
{ label: $gettext('Request Body'), value: 'body' },
|
||||
{ label: $gettext('Request Header'), value: 'header' },
|
||||
{ label: $gettext('Response Body'), value: 'response' },
|
||||
])
|
||||
|
||||
watch(show, (v) => {
|
||||
if (v) {
|
||||
model.value = defaultModel()
|
||||
policyId.value = null
|
||||
// 预填事件信息
|
||||
if (props.event) {
|
||||
model.value.detector = String(props.event.rule || '').split(':')[0] ?? ''
|
||||
model.value.path_prefix = String(props.event.uri || '').split('?')[0] ?? ''
|
||||
model.value.remark = $gettext('From event: %{ rule }', {
|
||||
rule: String(props.event.rule || ''),
|
||||
})
|
||||
}
|
||||
// 加载策略供选择
|
||||
useRequest(waf.policies()).onSuccess(({ data }: any) => {
|
||||
policies.value = Array.isArray(data) ? data : data?.items || []
|
||||
// 优先选中事件所属策略
|
||||
const evPolicy = props.event?.policy_id
|
||||
if (evPolicy && policies.value.some((p: any) => p.id === evPolicy)) {
|
||||
policyId.value = evPolicy
|
||||
} else if (policies.value.length > 0) {
|
||||
policyId.value = policies.value[0].id
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!policyId.value) {
|
||||
window.$message.error($gettext('Please select a policy'))
|
||||
return
|
||||
}
|
||||
if (!model.value.detector) {
|
||||
window.$message.error($gettext('Please select a detector'))
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
useRequest(waf.createExclusion(policyId.value, model.value))
|
||||
.onSuccess(() => {
|
||||
show.value = false
|
||||
window.$message.success($gettext('Added to allowlist successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="$gettext('Add False Positive to Allowlist')"
|
||||
style="width: 50vw; max-width: 600px"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="show = false"
|
||||
>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item :label="$gettext('Target Policy')">
|
||||
<n-select
|
||||
v-model:value="policyId"
|
||||
:options="policyOptions"
|
||||
:placeholder="$gettext('Select a policy')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-grid :cols="24" :x-gap="16">
|
||||
<n-form-item-gi :span="24" :label="$gettext('Detector')">
|
||||
<n-select v-model:value="model.detector" :options="detectorOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Input Target (optional)')">
|
||||
<n-select v-model:value="model.target" :options="targetOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Path Prefix (optional)')">
|
||||
<n-input v-model:value="model.path_prefix" placeholder="/path" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="24" :label="$gettext('Remark')">
|
||||
<n-input v-model:value="model.remark" type="textarea" :autosize="{ minRows: 2 }" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
<n-button type="info" block :loading="loading" :disabled="loading" @click="handleSubmit">
|
||||
{{ $gettext('Submit') }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'waf-index',
|
||||
})
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import app from '@/api/panel/app'
|
||||
import AttackMapView from '@/views/waf/AttackMapView.vue'
|
||||
import BindingView from '@/views/waf/BindingView.vue'
|
||||
import DecisionView from '@/views/waf/DecisionView.vue'
|
||||
import EventView from '@/views/waf/EventView.vue'
|
||||
import PolicyView from '@/views/waf/PolicyView.vue'
|
||||
import ReportView from '@/views/waf/ReportView.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const router = useRouter()
|
||||
const currentTab = ref('policy')
|
||||
|
||||
const ready = ref(false)
|
||||
const installed = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
useRequest(app.isInstalled('acewaf')).onSuccess(({ data }: any) => {
|
||||
installed.value = !!data
|
||||
ready.value = true
|
||||
})
|
||||
})
|
||||
|
||||
const goAppStore = () => {
|
||||
router.push({ name: 'app-index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer :show-footer="true">
|
||||
<template #tabs>
|
||||
<n-tabs v-if="ready && installed" v-model:value="currentTab" animated>
|
||||
<n-tab name="policy" :tab="$gettext('Policies')" />
|
||||
<n-tab name="decision" :tab="$gettext('Allow/Deny List')" />
|
||||
<n-tab name="binding" :tab="$gettext('Website Binding')" />
|
||||
<n-tab name="attack-map" :tab="$gettext('Attack Map')" />
|
||||
<n-tab name="report" :tab="$gettext('Dashboard')" />
|
||||
<n-tab name="event" :tab="$gettext('Event Logs')" />
|
||||
</n-tabs>
|
||||
</template>
|
||||
|
||||
<!-- 未安装 acewaf:提示安装组件 + 重装带 WAF 模块的 nginx -->
|
||||
<n-result
|
||||
v-if="ready && !installed"
|
||||
status="info"
|
||||
:title="$gettext('WAF is not installed')"
|
||||
class="mt-20"
|
||||
>
|
||||
<template #footer>
|
||||
<n-flex vertical align="center" :size="16">
|
||||
<n-text depth="3">
|
||||
{{
|
||||
$gettext(
|
||||
'WAF requires the acewaf component. Please install it from the App Store, and install or reinstall nginx with the WAF module enabled.',
|
||||
)
|
||||
}}
|
||||
</n-text>
|
||||
<n-button type="primary" @click="goAppStore">
|
||||
{{ $gettext('Go to App Store') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-result>
|
||||
|
||||
<!-- 已安装:功能页 -->
|
||||
<template v-else-if="ready">
|
||||
<policy-view v-if="currentTab === 'policy'" />
|
||||
<decision-view v-if="currentTab === 'decision'" />
|
||||
<binding-view v-if="currentTab === 'binding'" />
|
||||
<attack-map-view v-if="currentTab === 'attack-map'" />
|
||||
<report-view v-if="currentTab === 'report'" />
|
||||
<event-view v-if="currentTab === 'event'" />
|
||||
</template>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf, { policyApplyState, type WafPolicy } from '@/api/panel/waf'
|
||||
import AclRuleBuilder from '@/views/waf/components/AclRuleBuilder.vue'
|
||||
import RateLimitTable from '@/views/waf/components/RateLimitTable.vue'
|
||||
import ToleranceTable from '@/views/waf/components/ToleranceTable.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const props = defineProps<{ policyId: number }>()
|
||||
const emit = defineEmits<{ saved: [policy: WafPolicy] }>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const current = ref('basic')
|
||||
let loadToken = 0
|
||||
|
||||
const isEdit = computed(() => props.policyId > 0)
|
||||
const title = computed(() => (isEdit.value ? $gettext('Edit Policy') : $gettext('Create Policy')))
|
||||
|
||||
// 新策略的完整挑战配置
|
||||
const defaultChallenge = () => ({
|
||||
enabled: false,
|
||||
type: 'js_pow',
|
||||
difficulty: 16,
|
||||
clearance_ttl: 1800,
|
||||
challenge_ttl: 60,
|
||||
bind_fields: ['ip', 'ua'],
|
||||
captcha_length: 5,
|
||||
capacity: 100,
|
||||
})
|
||||
|
||||
// 默认 Bot 设置
|
||||
const defaultBot = () => ({
|
||||
enabled: false,
|
||||
block_ai_crawlers: false,
|
||||
allow_verified_search_engines: true,
|
||||
// n-dynamic-tags 运行时产出字符串数组,保存时再数值化为 ASN
|
||||
deny_asn: [] as string[],
|
||||
deny_country: [] as string[],
|
||||
})
|
||||
|
||||
// 完整策略默认模型
|
||||
const defaultModel = () => ({
|
||||
id: 0,
|
||||
name: '',
|
||||
enabled: true,
|
||||
mode: 'block',
|
||||
remark: '',
|
||||
security_level: 'standard',
|
||||
inspect_response: false,
|
||||
// 结构化规则(acl / ratelimit / tolerance 混存于同一数组 由 type 区分)
|
||||
rules: [] as any[],
|
||||
// Bot 与挑战扩展配置
|
||||
bot: defaultBot(),
|
||||
challenge: defaultChallenge(),
|
||||
})
|
||||
|
||||
const model = ref<any>(defaultModel())
|
||||
|
||||
// ACL 规则(type=acl)双向视图
|
||||
const aclRules = computed({
|
||||
get: () => model.value.rules.filter((r: any) => r.type === 'acl'),
|
||||
set: (v: any[]) => {
|
||||
model.value.rules = [...v, ...model.value.rules.filter((r: any) => r.type !== 'acl')]
|
||||
},
|
||||
})
|
||||
|
||||
// CC 限流规则(type=ratelimit)双向视图
|
||||
const rateLimitRules = computed({
|
||||
get: () => model.value.rules.filter((r: any) => r.type === 'ratelimit'),
|
||||
set: (v: any[]) => {
|
||||
model.value.rules = [...model.value.rules.filter((r: any) => r.type !== 'ratelimit'), ...v]
|
||||
},
|
||||
})
|
||||
|
||||
// 容忍度规则(type=tolerance)双向视图
|
||||
const toleranceRules = computed({
|
||||
get: () => model.value.rules.filter((r: any) => r.type === 'tolerance'),
|
||||
set: (v: any[]) => {
|
||||
model.value.rules = [...model.value.rules.filter((r: any) => r.type !== 'tolerance'), ...v]
|
||||
},
|
||||
})
|
||||
|
||||
watch([show, () => props.policyId], ([v]) => {
|
||||
const token = ++loadToken
|
||||
if (v) {
|
||||
current.value = 'basic'
|
||||
if (isEdit.value) {
|
||||
loading.value = true
|
||||
useRequest(waf.policy(props.policyId))
|
||||
.onSuccess(({ data }: any) => {
|
||||
if (token !== loadToken || !show.value) return
|
||||
model.value = {
|
||||
...defaultModel(),
|
||||
...data,
|
||||
rules: Array.isArray(data.rules) ? data.rules : [],
|
||||
bot: { ...defaultBot(), ...data.bot },
|
||||
challenge: { ...defaultChallenge(), ...data.challenge },
|
||||
}
|
||||
})
|
||||
.onComplete(() => {
|
||||
if (token === loadToken) loading.value = false
|
||||
})
|
||||
} else {
|
||||
loading.value = false
|
||||
model.value = defaultModel()
|
||||
}
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const modeOptions = computed(() => [
|
||||
{ label: $gettext('Block'), value: 'block' },
|
||||
{ label: $gettext('Observe (log only)'), value: 'observe' },
|
||||
])
|
||||
|
||||
const securityLevelOptions = computed(() => [
|
||||
{ label: $gettext('Standard'), value: 'standard' },
|
||||
{ label: $gettext('Strict'), value: 'strict' },
|
||||
])
|
||||
|
||||
const bindFieldOptions = computed(() => [
|
||||
{ label: $gettext('IP'), value: 'ip' },
|
||||
{ label: $gettext('User-Agent'), value: 'ua' },
|
||||
{ label: $gettext('Accept-Language'), value: 'accept_language' },
|
||||
])
|
||||
|
||||
const challengeTypeOptions = computed(() => [
|
||||
{ label: $gettext('JS Proof of Work'), value: 'js_pow' },
|
||||
{ label: $gettext('Captcha'), value: 'captcha' },
|
||||
{ label: $gettext('Waiting Room'), value: 'waiting_room' },
|
||||
])
|
||||
|
||||
const handleSave = () => {
|
||||
if (loading.value || saving.value) return
|
||||
if (!model.value.name.trim()) {
|
||||
window.$message.error($gettext('Please enter a policy name'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
const policy = { ...model.value }
|
||||
delete policy.exclusions
|
||||
delete policy.version
|
||||
delete policy.target_version
|
||||
delete policy.applied_version
|
||||
delete policy.last_error
|
||||
const payload = {
|
||||
...policy,
|
||||
rules: model.value.rules,
|
||||
// n-dynamic-tags 始终产出字符串数组,ASN 需数值化后下发,否则 agent 解 []uint32 报错导致整条策略保存失败
|
||||
bot: {
|
||||
...model.value.bot,
|
||||
deny_asn: (model.value.bot?.deny_asn || [])
|
||||
.map((v: any) => Number(v))
|
||||
.filter((n: number) => Number.isInteger(n) && n >= 0),
|
||||
},
|
||||
}
|
||||
const request = isEdit.value
|
||||
? waf.updatePolicy(props.policyId, payload)
|
||||
: waf.createPolicy(payload)
|
||||
useRequest(request)
|
||||
.onSuccess(({ data }: { data: WafPolicy }) => {
|
||||
emit('saved', data)
|
||||
show.value = false
|
||||
switch (policyApplyState(data)) {
|
||||
case 'applied':
|
||||
window.$message.success($gettext('Policy saved and applied'))
|
||||
break
|
||||
case 'pending':
|
||||
window.$message.info($gettext('Policy saved and pending application'))
|
||||
break
|
||||
case 'failed':
|
||||
window.$message.error(
|
||||
`${$gettext('Policy saved but failed to apply')}: ${data.last_error || '-'}`,
|
||||
)
|
||||
break
|
||||
default:
|
||||
window.$message.success($gettext('Policy saved'))
|
||||
}
|
||||
})
|
||||
.onComplete(() => {
|
||||
saving.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="title"
|
||||
:style="{ width: '80vw', maxWidth: '1100px' }"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="show = false"
|
||||
>
|
||||
<n-spin :show="loading">
|
||||
<n-tabs v-model:value="current" type="line" animated>
|
||||
<!-- 基础设置 -->
|
||||
<n-tab-pane name="basic" :tab="$gettext('Basic')">
|
||||
<n-form :model="model" label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="16">
|
||||
<n-form-item-gi :span="12" :label="$gettext('Name')">
|
||||
<n-input v-model:value="model.name" :placeholder="$gettext('Policy name')" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="6" :label="$gettext('Enabled')">
|
||||
<n-switch v-model:value="model.enabled" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="6" :label="$gettext('Mode')">
|
||||
<n-select v-model:value="model.mode" :options="modeOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="24" :label="$gettext('Remark')">
|
||||
<n-input
|
||||
v-model:value="model.remark"
|
||||
type="textarea"
|
||||
:placeholder="$gettext('Optional')"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- CC 限流 -->
|
||||
<n-tab-pane name="ratelimit" :tab="$gettext('Rate Limit (CC)')">
|
||||
<rate-limit-table v-model="rateLimitRules" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 容忍度拉黑 -->
|
||||
<n-tab-pane name="tolerance" :tab="$gettext('Tolerance Ban')">
|
||||
<tolerance-table v-model="toleranceRules" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 黑白名单(ACL 与或非构建器) -->
|
||||
<n-tab-pane name="acl" :tab="$gettext('Access Rules')">
|
||||
<acl-rule-builder v-model="aclRules" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 语义检测 -->
|
||||
<n-tab-pane name="security" :tab="$gettext('Semantic Detection')">
|
||||
<n-form :model="model" label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="16">
|
||||
<n-form-item-gi :span="12" :label="$gettext('Security Level')">
|
||||
<n-select v-model:value="model.security_level" :options="securityLevelOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Response Inspection')">
|
||||
<n-switch v-model:value="model.inspect_response" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- Bot 设置 -->
|
||||
<n-tab-pane name="bot" :tab="$gettext('Bot Management')">
|
||||
<n-form :model="model.bot" label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="16">
|
||||
<n-form-item-gi :span="8" :label="$gettext('Enable Bot Management')">
|
||||
<n-switch v-model:value="model.bot.enabled" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Block AI Crawlers')">
|
||||
<n-switch v-model:value="model.bot.block_ai_crawlers" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Allow Verified Search Engines')">
|
||||
<n-switch v-model:value="model.bot.allow_verified_search_engines" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Auto-ban flagged bots by country')">
|
||||
<n-dynamic-tags
|
||||
v-model:value="model.bot.deny_country"
|
||||
:placeholder="$gettext('e.g., CN, RU')"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Auto-ban flagged bots by ASN')">
|
||||
<n-dynamic-tags
|
||||
v-model:value="model.bot.deny_asn"
|
||||
:placeholder="$gettext('e.g., 4134')"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 挑战设置 -->
|
||||
<n-tab-pane name="challenge" :tab="$gettext('Challenge')">
|
||||
<n-form :model="model.challenge" label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="16">
|
||||
<n-form-item-gi
|
||||
:span="24"
|
||||
:label="$gettext('Enable Challenge (all requests must pass verification)')"
|
||||
>
|
||||
<n-switch v-model:value="model.challenge.enabled" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Challenge Type')">
|
||||
<n-select v-model:value="model.challenge.type" :options="challengeTypeOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="model.challenge.type === 'js_pow'"
|
||||
:span="12"
|
||||
:label="$gettext('Difficulty (PoW leading zero bits)')"
|
||||
>
|
||||
<n-input-number
|
||||
v-model:value="model.challenge.difficulty"
|
||||
:min="1"
|
||||
:max="32"
|
||||
w-full
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Clearance TTL (seconds)')">
|
||||
<n-input-number v-model:value="model.challenge.clearance_ttl" :min="1" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Challenge TTL (seconds)')">
|
||||
<n-input-number v-model:value="model.challenge.challenge_ttl" :min="1" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Bind Fields')">
|
||||
<n-select
|
||||
v-model:value="model.challenge.bind_fields"
|
||||
multiple
|
||||
:options="bindFieldOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="model.challenge.type === 'captcha'"
|
||||
:span="12"
|
||||
:label="$gettext('Captcha Length')"
|
||||
>
|
||||
<n-input-number
|
||||
v-model:value="model.challenge.captcha_length"
|
||||
:min="4"
|
||||
:max="8"
|
||||
w-full
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="model.challenge.type === 'waiting_room'"
|
||||
:span="12"
|
||||
:label="$gettext('Waiting Room Capacity')"
|
||||
>
|
||||
<n-input-number
|
||||
v-model:value="model.challenge.capacity"
|
||||
:min="1"
|
||||
:max="4096"
|
||||
w-full
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-spin>
|
||||
<template #footer>
|
||||
<n-button
|
||||
type="info"
|
||||
block
|
||||
:loading="saving"
|
||||
:disabled="loading || saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NTag, NTooltip } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf, {
|
||||
policyApplyState,
|
||||
type WafPolicy,
|
||||
type WafPolicyApplyState,
|
||||
type WafPolicyStatus,
|
||||
} from '@/api/panel/waf'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
import PolicyEditor from '@/views/waf/PolicyEditor.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete } = useConfirm()
|
||||
|
||||
const loading = ref(false)
|
||||
const policies = ref<WafPolicy[]>([])
|
||||
let requestPending = false
|
||||
let statusRequestPending = false
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let active = true
|
||||
|
||||
const editorShow = ref(false)
|
||||
const editPolicyId = ref<number>(0)
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
|
||||
const schedulePolling = () => {
|
||||
stopPolling()
|
||||
if (policies.value.some((policy) => policyApplyState(policy) === 'pending')) {
|
||||
pollTimer = setTimeout(pollPendingPolicies, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const pollPendingPolicies = () => {
|
||||
if (statusRequestPending) return
|
||||
const pending = policies.value.filter((policy) => policyApplyState(policy) === 'pending')
|
||||
if (pending.length === 0) return
|
||||
|
||||
statusRequestPending = true
|
||||
let remaining = pending.length
|
||||
for (const policy of pending) {
|
||||
useRequest(waf.policyStatus(policy.id))
|
||||
.onSuccess(({ data }: { data: WafPolicyStatus }) => {
|
||||
Object.assign(policy, data)
|
||||
})
|
||||
.onComplete(() => {
|
||||
remaining--
|
||||
if (remaining === 0) {
|
||||
statusRequestPending = false
|
||||
if (active) schedulePolling()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadPolicies = (showLoading = true) => {
|
||||
if (requestPending) return
|
||||
stopPolling()
|
||||
requestPending = true
|
||||
if (showLoading) loading.value = true
|
||||
useRequest(waf.policies())
|
||||
.onSuccess(({ data }: any) => {
|
||||
// agent 直接返回数组
|
||||
policies.value = Array.isArray(data) ? data : data?.items || []
|
||||
})
|
||||
.onComplete(() => {
|
||||
requestPending = false
|
||||
if (showLoading) loading.value = false
|
||||
schedulePolling()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaved = (policy: WafPolicy) => {
|
||||
const index = policies.value.findIndex((item) => item.id === policy.id)
|
||||
if (index === -1) {
|
||||
policies.value.unshift(policy)
|
||||
} else {
|
||||
policies.value[index] = policy
|
||||
}
|
||||
schedulePolling()
|
||||
}
|
||||
|
||||
const applyStatusMeta = (
|
||||
state: WafPolicyApplyState,
|
||||
): { type: 'default' | 'error' | 'success' | 'warning'; label: string } => {
|
||||
switch (state) {
|
||||
case 'pending':
|
||||
return { type: 'warning', label: $gettext('Pending') }
|
||||
case 'applied':
|
||||
return { type: 'success', label: $gettext('Applied') }
|
||||
case 'failed':
|
||||
return { type: 'error', label: $gettext('Failed') }
|
||||
default:
|
||||
return { type: 'default', label: $gettext('Saved') }
|
||||
}
|
||||
}
|
||||
|
||||
const renderApplyStatus = (row: WafPolicy) => {
|
||||
const state = policyApplyState(row)
|
||||
const meta = applyStatusMeta(state)
|
||||
const tag = () => h(NTag, { type: meta.type }, { default: () => meta.label })
|
||||
if (state !== 'failed' || !row.last_error) return tag()
|
||||
return h(NTooltip, null, {
|
||||
trigger: tag,
|
||||
default: () => String(row.last_error),
|
||||
})
|
||||
}
|
||||
|
||||
const columns: any = [
|
||||
{ title: 'ID', key: 'id', width: 80 },
|
||||
{
|
||||
title: $gettext('Name'),
|
||||
key: 'name',
|
||||
minWidth: 150,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Enabled'),
|
||||
key: 'enabled',
|
||||
width: 110,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.enabled ? 'success' : 'default' },
|
||||
{ default: () => (row.enabled ? $gettext('Enabled') : $gettext('Disabled')) },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Mode'),
|
||||
key: 'mode',
|
||||
width: 120,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.mode === 'observe' ? 'warning' : 'error' },
|
||||
{ default: () => (row.mode === 'observe' ? $gettext('Observe') : $gettext('Block')) },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Security Level'),
|
||||
key: 'security_level',
|
||||
width: 120,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.security_level === 'strict' ? 'warning' : 'info' },
|
||||
{
|
||||
default: () =>
|
||||
row.security_level === 'strict' ? $gettext('Strict') : $gettext('Standard'),
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Version'),
|
||||
key: 'version',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: $gettext('Apply Status'),
|
||||
key: 'apply_status',
|
||||
width: 120,
|
||||
render: renderApplyStatus,
|
||||
},
|
||||
{
|
||||
title: $gettext('Remark'),
|
||||
key: 'remark',
|
||||
minWidth: 120,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row: any) => row.remark || '-',
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
render(row: any) {
|
||||
return h(NFlex, { justify: 'center' }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
editPolicyId.value = row.id
|
||||
editorShow.value = true
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Edit') },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
content: $gettext('Are you sure you want to delete this policy?'),
|
||||
countdown: 5,
|
||||
})
|
||||
if (ok) handleDelete(row.id)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Delete') },
|
||||
),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
useRequest(waf.deletePolicy(id)).onSuccess(() => {
|
||||
loadPolicies()
|
||||
window.$message.success($gettext('Deleted successfully'))
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
editPolicyId.value = 0
|
||||
editorShow.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPolicies()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex items-center>
|
||||
<n-button type="primary" @click="handleCreate">
|
||||
{{ $gettext('Create Policy') }}
|
||||
</n-button>
|
||||
<n-button @click="loadPolicies()">{{ $gettext('Refresh') }}</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:scroll-x="1120"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="policies"
|
||||
:row-key="(row: any) => row.id"
|
||||
/>
|
||||
</n-flex>
|
||||
<policy-editor v-model:show="editorShow" :policy-id="editPolicyId" @saved="handleSaved" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { BarChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import waf from '@/api/panel/waf'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
use([CanvasRenderer, BarChart, PieChart, TooltipComponent, GridComponent, LegendComponent])
|
||||
|
||||
const loading = ref(false)
|
||||
const rangeDays = ref(7)
|
||||
const stats = ref<any>({ total: 0, by_action: [], by_severity: [], top_ip: [] })
|
||||
|
||||
const rangeOptions = computed(() => [
|
||||
{ label: $gettext('Last 24 hours'), value: 1 },
|
||||
{ label: $gettext('Last 7 days'), value: 7 },
|
||||
{ label: $gettext('Last 30 days'), value: 30 },
|
||||
])
|
||||
|
||||
const since = computed(() => Math.floor(Date.now() / 1000) - rangeDays.value * 86400)
|
||||
|
||||
const loadData = () => {
|
||||
loading.value = true
|
||||
useRequest(waf.stats(since.value))
|
||||
.onSuccess(({ data }: any) => {
|
||||
stats.value = {
|
||||
total: data?.total || 0,
|
||||
by_action: data?.by_action || [],
|
||||
by_severity: data?.by_severity || [],
|
||||
top_ip: data?.top_ip || [],
|
||||
}
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(rangeDays, () => loadData())
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
// 按动作统计(用于统计卡片)
|
||||
const actionCount = (action: string): number => {
|
||||
const item = stats.value.by_action.find((i: any) => i.key === action)
|
||||
return item ? item.count : 0
|
||||
}
|
||||
|
||||
// 动作分布饼图
|
||||
const actionPieOption = computed<EChartsOption>(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '65%'],
|
||||
data: stats.value.by_action.map((i: any) => ({ name: i.key, value: i.count })),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// 严重度分布柱状图
|
||||
const severityBarOption = computed<EChartsOption>(() => {
|
||||
const data = [...stats.value.by_severity].sort((a: any, b: any) => Number(a.key) - Number(b.key))
|
||||
return {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((i: any) => `${$gettext('Level')} ${i.key}`),
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'bar', data: data.map((i: any) => i.count), barMaxWidth: 40 }],
|
||||
}
|
||||
})
|
||||
|
||||
const topIpColumns: any = [
|
||||
{ title: $gettext('Client IP'), key: 'key', minWidth: 160 },
|
||||
{
|
||||
title: $gettext('Attacks'),
|
||||
key: 'count',
|
||||
width: 120,
|
||||
sorter: (a: any, b: any) => a.count - b.count,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex align="center">
|
||||
<n-select v-model:value="rangeDays" :options="rangeOptions" class="w-40" />
|
||||
<n-button @click="loadData">{{ $gettext('Refresh') }}</n-button>
|
||||
</n-flex>
|
||||
<n-spin :show="loading">
|
||||
<!-- 总览卡片 -->
|
||||
<n-grid :cols="24" :x-gap="16" :y-gap="16">
|
||||
<n-gi :span="6">
|
||||
<n-card :bordered="false">
|
||||
<n-statistic :label="$gettext('Total Events')" :value="stats.total" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-card :bordered="false">
|
||||
<n-statistic :label="$gettext('Blocked')" :value="actionCount('block')" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-card :bordered="false">
|
||||
<n-statistic :label="$gettext('Challenged')" :value="actionCount('challenge')" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-card :bordered="false">
|
||||
<n-statistic :label="$gettext('Banned')" :value="actionCount('ban')" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 图表区 -->
|
||||
<n-grid :cols="24" :x-gap="16" :y-gap="16" class="mt-4">
|
||||
<n-gi :span="12">
|
||||
<n-card :bordered="false" :title="$gettext('Action Distribution')">
|
||||
<v-chart
|
||||
v-if="stats.by_action.length > 0"
|
||||
class="h-300px"
|
||||
:option="actionPieOption"
|
||||
autoresize
|
||||
/>
|
||||
<n-empty v-else :description="$gettext('No data')" class="my-10" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi :span="12">
|
||||
<n-card :bordered="false" :title="$gettext('Severity Distribution')">
|
||||
<v-chart
|
||||
v-if="stats.by_severity.length > 0"
|
||||
class="h-300px"
|
||||
:option="severityBarOption"
|
||||
autoresize
|
||||
/>
|
||||
<n-empty v-else :description="$gettext('No data')" class="my-10" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- Top 攻击源 -->
|
||||
<n-card :bordered="false" :title="$gettext('Top Attack Source IPs')" class="mt-4">
|
||||
<n-data-table
|
||||
:columns="topIpColumns"
|
||||
:data="stats.top_ip"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// ACL 规则数组
|
||||
const rules = defineModel<any[]>({ required: true })
|
||||
|
||||
const actionOptions = computed(() => [
|
||||
{ label: $gettext('Allow'), value: 'allow' },
|
||||
{ label: $gettext('Deny'), value: 'deny' },
|
||||
{ label: $gettext('Challenge'), value: 'challenge' },
|
||||
{ label: $gettext('Log only'), value: 'log' },
|
||||
])
|
||||
|
||||
// 字段:含子键的字段(header/arg/cookie)需要额外 name
|
||||
const fieldOptions = computed(() => [
|
||||
{ label: $gettext('IP'), value: 'ip' },
|
||||
{ label: $gettext('User-Agent'), value: 'ua' },
|
||||
{ label: $gettext('URI'), value: 'uri' },
|
||||
{ label: $gettext('Host'), value: 'host' },
|
||||
{ label: $gettext('Method'), value: 'method' },
|
||||
{ label: $gettext('Header'), value: 'header' },
|
||||
{ label: $gettext('Argument'), value: 'arg' },
|
||||
{ label: $gettext('Cookie'), value: 'cookie' },
|
||||
{ label: $gettext('JA4'), value: 'ja4' },
|
||||
])
|
||||
|
||||
const operatorOptions = computed(() => [
|
||||
{ label: $gettext('Equals'), value: 'eq' },
|
||||
{ label: $gettext('Contains'), value: 'contains' },
|
||||
{ label: $gettext('Regex'), value: 'regex' },
|
||||
{ label: $gettext('Prefix'), value: 'prefix' },
|
||||
{ label: $gettext('Suffix'), value: 'suffix' },
|
||||
{ label: $gettext('IP Match (CIDR)'), value: 'ipmatch' },
|
||||
])
|
||||
|
||||
const operatorsForField = (field: string) =>
|
||||
field === 'ja4'
|
||||
? operatorOptions.value.filter((option) => option.value !== 'ipmatch')
|
||||
: operatorOptions.value
|
||||
|
||||
// 需要子键名的字段
|
||||
const needsName = (field: string) => ['header', 'arg', 'cookie'].includes(field)
|
||||
|
||||
let idCounter = 0
|
||||
const genKey = () => `_acl_${Date.now()}_${++idCounter}`
|
||||
|
||||
const newCondition = () => ({
|
||||
_key: genKey(),
|
||||
field: 'ip',
|
||||
name: '',
|
||||
operator: 'ipmatch',
|
||||
value: '',
|
||||
negate: false,
|
||||
})
|
||||
|
||||
const addRule = () => {
|
||||
rules.value = [
|
||||
...(rules.value || []),
|
||||
{
|
||||
_key: genKey(),
|
||||
type: 'acl',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
name: '',
|
||||
uri_pattern: '',
|
||||
action: 'deny',
|
||||
negate: false,
|
||||
conditions: [newCondition()],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
const next = [...(rules.value || [])]
|
||||
next.splice(index, 1)
|
||||
rules.value = next
|
||||
}
|
||||
|
||||
const addCondition = (rule: any) => {
|
||||
if (!rule.conditions) rule.conditions = []
|
||||
rule.conditions.push(newCondition())
|
||||
}
|
||||
|
||||
const removeCondition = (rule: any, index: number) => {
|
||||
rule.conditions.splice(index, 1)
|
||||
}
|
||||
|
||||
// 确保已有规则与条件带本地唯一键
|
||||
watchEffect(() => {
|
||||
rules.value?.forEach((r: any) => {
|
||||
if (!r._key) r._key = genKey()
|
||||
r.conditions?.forEach((c: any) => {
|
||||
if (!c._key) c._key = genKey()
|
||||
if (c.field === 'ja4' && c.operator === 'ipmatch') c.operator = 'eq'
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="16">
|
||||
<n-alert type="info" :bordered="false">
|
||||
{{
|
||||
$gettext(
|
||||
'Each rule combines multiple conditions with AND. Multiple rules are evaluated as OR. A rule (or single condition) can be negated with NOT. The action applies when the rule matches.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
|
||||
<n-empty v-if="!rules || rules.length === 0" :description="$gettext('No access rules')" />
|
||||
|
||||
<template v-for="(rule, rIndex) in rules" :key="rule._key">
|
||||
<!-- 规则之间的 OR 分隔 -->
|
||||
<n-divider v-if="rIndex > 0" class="!my-0">
|
||||
<n-tag type="warning" size="small">{{ $gettext('OR') }}</n-tag>
|
||||
</n-divider>
|
||||
|
||||
<n-card closable size="small" @close="removeRule(rIndex)">
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-switch v-model:value="rule.enabled" size="small" />
|
||||
<span>{{ $gettext('Rule') }} #{{ rIndex + 1 }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="12">
|
||||
<n-form-item-gi :span="6" :label="$gettext('Name')">
|
||||
<n-input v-model:value="rule.name" :placeholder="$gettext('Optional')" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="6" :label="$gettext('Action')">
|
||||
<n-select v-model:value="rule.action" :options="actionOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="6" :label="$gettext('Priority')">
|
||||
<n-input-number v-model:value="rule.priority" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="6" :label="$gettext('Negate Whole Rule (NOT)')">
|
||||
<n-switch v-model:value="rule.negate" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="24" :label="$gettext('URI Regex')">
|
||||
<n-input v-model:value="rule.uri_pattern" :placeholder="$gettext('Optional')" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
|
||||
<!-- 条件列表(AND) -->
|
||||
<n-flex vertical :size="8">
|
||||
<template v-for="(cond, cIndex) in rule.conditions" :key="cond._key">
|
||||
<n-flex v-if="Number(cIndex) > 0" justify="center" class="!my-1">
|
||||
<n-tag type="success" size="small">{{ $gettext('AND') }}</n-tag>
|
||||
</n-flex>
|
||||
<n-flex align="flex-start" :size="8" :wrap="false">
|
||||
<n-select
|
||||
v-model:value="cond.field"
|
||||
:options="fieldOptions"
|
||||
class="w-32"
|
||||
:placeholder="$gettext('Field')"
|
||||
/>
|
||||
<n-input
|
||||
v-if="needsName(cond.field)"
|
||||
v-model:value="cond.name"
|
||||
class="w-32"
|
||||
:placeholder="$gettext('Key name')"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="cond.operator"
|
||||
:options="operatorsForField(cond.field)"
|
||||
class="w-36"
|
||||
:placeholder="$gettext('Operator')"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="cond.value"
|
||||
class="flex-1"
|
||||
:placeholder="$gettext('Value')"
|
||||
/>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button
|
||||
:type="cond.negate ? 'warning' : 'default'"
|
||||
secondary
|
||||
@click="cond.negate = !cond.negate"
|
||||
>
|
||||
{{ $gettext('NOT') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ $gettext('Negate this single condition') }}
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
type="error"
|
||||
secondary
|
||||
:disabled="rule.conditions.length <= 1"
|
||||
@click="removeCondition(rule, Number(cIndex))"
|
||||
>
|
||||
<the-icon icon="mdi:close" :size="16" />
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<n-button size="small" dashed @click="addCondition(rule)">
|
||||
{{ $gettext('Add Condition (AND)') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<n-button type="primary" dashed @click="addRule">
|
||||
{{ $gettext('Add Rule (OR)') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 规则数组(type=ratelimit),由父级 v-model 提供
|
||||
const rules = defineModel<any[]>({ required: true })
|
||||
|
||||
const actionOptions = computed(() => [
|
||||
{ label: $gettext('Block'), value: 'block' },
|
||||
{ label: $gettext('Challenge'), value: 'challenge' },
|
||||
{ label: $gettext('Log only'), value: 'log' },
|
||||
])
|
||||
|
||||
// 挑战类型:空=沿用策略全局挑战类型
|
||||
const challengeTypeOptions = computed(() => [
|
||||
{ label: $gettext('Use policy default'), value: '' },
|
||||
{ label: $gettext('JS Proof of Work'), value: 'js_pow' },
|
||||
{ label: $gettext('Captcha'), value: 'captcha' },
|
||||
{ label: $gettext('Waiting Room'), value: 'waiting_room' },
|
||||
])
|
||||
|
||||
let idCounter = 0
|
||||
const genKey = () => `_rl_${Date.now()}_${++idCounter}`
|
||||
|
||||
const addRule = () => {
|
||||
rules.value = [
|
||||
...(rules.value || []),
|
||||
{
|
||||
_key: genKey(),
|
||||
type: 'ratelimit',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
name: '',
|
||||
uri_pattern: '',
|
||||
key: 'ip',
|
||||
capacity: 100,
|
||||
leak_per_sec: 10,
|
||||
action: 'block',
|
||||
// challenge_type:action=challenge 时本规则专用挑战类型,空=沿用策略全局挑战类型
|
||||
challenge_type: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
const next = [...(rules.value || [])]
|
||||
next.splice(index, 1)
|
||||
rules.value = next
|
||||
}
|
||||
|
||||
// 确保已有规则带本地唯一键
|
||||
watchEffect(() => {
|
||||
rules.value?.forEach((r: any) => {
|
||||
if (!r._key) r._key = genKey()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="16">
|
||||
<n-alert type="info" :bordered="false">
|
||||
{{
|
||||
$gettext(
|
||||
'Leaky bucket rate limiting. Differentiate per URL: capacity is the burst size, leak rate is requests drained per second. Aggregation key supports ip, session, cookie:<name>, arg:<name>, header:<name>.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
|
||||
<n-empty v-if="!rules || rules.length === 0" :description="$gettext('No rate limit rules')" />
|
||||
|
||||
<n-card
|
||||
v-for="(rule, index) in rules"
|
||||
:key="rule._key"
|
||||
closable
|
||||
size="small"
|
||||
@close="removeRule(index)"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-switch v-model:value="rule.enabled" size="small" />
|
||||
<span>{{ $gettext('Rule') }} #{{ index + 1 }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="12">
|
||||
<n-form-item-gi :span="8" :label="$gettext('Name')">
|
||||
<n-input v-model:value="rule.name" :placeholder="$gettext('Optional')" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="16" :label="$gettext('URI Prefix (~regex, empty = whole site)')">
|
||||
<n-input v-model:value="rule.uri_pattern" placeholder="/api/" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Aggregation Key')">
|
||||
<n-input v-model:value="rule.key" placeholder="ip" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="5" :label="$gettext('Capacity (burst)')">
|
||||
<n-input-number v-model:value="rule.capacity" :min="1" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="5" :label="$gettext('Leak per Second')">
|
||||
<n-input-number v-model:value="rule.leak_per_sec" :min="1" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="6" :label="$gettext('Action')">
|
||||
<n-select v-model:value="rule.action" :options="actionOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="rule.action === 'challenge'"
|
||||
:span="24"
|
||||
:label="$gettext('Challenge Type')"
|
||||
>
|
||||
<n-select v-model:value="rule.challenge_type" :options="challengeTypeOptions" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-button type="primary" dashed @click="addRule">
|
||||
{{ $gettext('Add Rate Limit Rule') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 规则数组(type=tolerance),由父级 v-model 提供
|
||||
const rules = defineModel<any[]>({ required: true })
|
||||
|
||||
// category 契约为全小写,与数据面字节精确比较
|
||||
const categoryOptions = computed(() => [
|
||||
{ label: $gettext('Challenge failures'), value: 'challenge_fail' },
|
||||
{ label: $gettext('Attacks (injection etc.)'), value: 'attack' },
|
||||
{ label: $gettext('Rate limit hits (CC)'), value: 'cc' },
|
||||
])
|
||||
|
||||
let idCounter = 0
|
||||
const genKey = () => `_tol_${Date.now()}_${++idCounter}`
|
||||
|
||||
const addRule = () => {
|
||||
rules.value = [
|
||||
...(rules.value || []),
|
||||
{
|
||||
_key: genKey(),
|
||||
type: 'tolerance',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
name: '',
|
||||
category: 'attack',
|
||||
threshold: 10,
|
||||
window_sec: 60,
|
||||
ban_seconds: 600,
|
||||
ban_prefix: 0,
|
||||
push_l4: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
const next = [...(rules.value || [])]
|
||||
next.splice(index, 1)
|
||||
rules.value = next
|
||||
}
|
||||
|
||||
// 确保已有规则带本地唯一键
|
||||
watchEffect(() => {
|
||||
rules.value?.forEach((r: any) => {
|
||||
if (!r._key) r._key = genKey()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="16">
|
||||
<n-alert type="info" :bordered="false">
|
||||
{{
|
||||
$gettext(
|
||||
'Tolerance-based banning: count violations per IP within a fixed window, ban when the threshold is exceeded. Ban prefix 0 bans the exact IP; e.g. 24 bans the whole /24 subnet (useful against botnets).'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
|
||||
<n-empty v-if="!rules || rules.length === 0" :description="$gettext('No tolerance rules')" />
|
||||
|
||||
<n-card
|
||||
v-for="(rule, index) in rules"
|
||||
:key="rule._key"
|
||||
closable
|
||||
size="small"
|
||||
@close="removeRule(index)"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-switch v-model:value="rule.enabled" size="small" />
|
||||
<span>{{ $gettext('Rule') }} #{{ index + 1 }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="24" :x-gap="12">
|
||||
<n-form-item-gi :span="8" :label="$gettext('Name')">
|
||||
<n-input v-model:value="rule.name" :placeholder="$gettext('Optional')" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Violation Category')">
|
||||
<n-select v-model:value="rule.category" :options="categoryOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="4" :label="$gettext('Threshold')">
|
||||
<n-input-number v-model:value="rule.threshold" :min="1" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="4" :label="$gettext('Window (seconds)')">
|
||||
<n-input-number v-model:value="rule.window_sec" :min="1" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Ban Duration (seconds, 0 = permanent)')">
|
||||
<n-input-number v-model:value="rule.ban_seconds" :min="0" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Ban Prefix (CIDR bits, 0 = exact IP)')">
|
||||
<n-input-number v-model:value="rule.ban_prefix" :min="0" :max="128" w-full />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="8" :label="$gettext('Push to L4 Firewall')">
|
||||
<n-switch v-model:value="rule.push_l4" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-button type="primary" dashed @click="addRule">
|
||||
{{ $gettext('Add Tolerance Rule') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RouteType } from '@/types/router'
|
||||
|
||||
const Layout = () => import('@/layouts/IndexView.vue')
|
||||
|
||||
export default {
|
||||
name: 'waf',
|
||||
path: '/waf',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 41,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'waf-index',
|
||||
path: '',
|
||||
component: () => import('./IndexView.vue'),
|
||||
meta: {
|
||||
title: 'WAF',
|
||||
icon: 'mdi:shield-bug',
|
||||
role: ['admin'],
|
||||
requireAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RouteType
|
||||
Reference in New Issue
Block a user