feat(waf): 接入 acewaf 管理模块

This commit is contained in:
耗子
2026-08-01 00:58:32 +08:00
parent a4e3a8d38f
commit fb133971db
34 changed files with 4570 additions and 6 deletions
+18 -1
View File
@@ -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)
+23
View File
@@ -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)
}
+2
View File
@@ -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
View File
@@ -14,5 +14,5 @@ var ProviderSet = wire.NewSet(
NewSettingUsecase, NewSSHUsecase, NewTamperUsecase, NewTaskUsecase,
NewTemplateUsecase, NewUserUsecase, NewUserPasskeyUsecase,
NewUserTokenUsecase, NewWebHookUsecase, NewWebsiteUsecase,
NewWebsiteStatUsecase,
NewWebsiteStatUsecase, NewWafUsecase,
)
+1
View File
@@ -31,6 +31,7 @@ const (
OperationTypeMonitor = "monitor"
OperationTypeWebhook = "webhook"
OperationTypeUser = "user"
OperationTypeWaf = "waf"
)
// LogEntry 日志条目
+124
View File
@@ -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) }
+3 -2
View File
@@ -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
}
+1 -1
View File
@@ -15,5 +15,5 @@ var ProviderSet = wire.NewSet(
NewSettingRepo, NewSSHRepo, NewTamperRepo, NewTaskRepo,
NewTemplateRepo, NewUserRepo, NewUserPasskeyRepo,
NewUserTokenRepo, NewWebHookRepo, NewWebsiteRepo,
NewWebsiteStatRepo,
NewWebsiteStatRepo, NewWafRepo,
)
+516
View File
@@ -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
}
+71
View File
@@ -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"])
}
+6 -1
View File
@@ -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 {
+9
View File
@@ -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 {
+15
View File
@@ -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"`
}
+2
View File
@@ -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),
}
}
+38
View File
@@ -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"}},
}
}
+1
View File
@@ -21,4 +21,5 @@ var ProviderSet = wire.NewSet(
NewToolboxNetworkService, NewToolboxSystemService, NewToolboxBenchmarkService,
NewToolboxSSHService, NewToolboxDiskService, NewToolboxLogService,
NewToolboxMigrationService, NewWsService,
NewWafService,
)
+305
View File
@@ -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)
}
+1020
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -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`),
}
+161
View File
@@ -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>
+107
View File
@@ -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>
+121
View File
@@ -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>
+99
View File
@@ -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>
+147
View File
@@ -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>
+207
View File
@@ -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>
+139
View File
@@ -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>
+84
View File
@@ -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>
+369
View File
@@ -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>
+260
View File
@@ -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>
+163
View File
@@ -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_typeaction=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>
+25
View File
@@ -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