From 89ae2bad67cd954ec01691c5287523f0bb99951f Mon Sep 17 00:00:00 2001 From: samwaf Date: Sat, 22 Aug 2026 18:31:41 +0800 Subject: [PATCH] feat: show the release version in upgrade notes --- api/entrance.go | 3 + api/waf_upgrade_notice_api.go | 156 ++++++++ model/request/waf_upgrade_notice_req.go | 25 ++ model/response/waf_upgrade_notice_resp.go | 45 +++ model/upgrade_notice_record.go | 38 ++ router/entrance.go | 1 + router/waf_upgrade_notice_router.go | 28 ++ service/waf_service/cross_engine_core_test.go | 2 + .../cross_engine_upgrade_notice_test.go | 200 ++++++++++ .../waf_service/waf_upgrade_notice_service.go | 368 ++++++++++++++++++ wafdb/migrations_core.go | 20 + wafmangeweb/localserver.go | 3 + waftask/task_config.go | 68 +++- waftask/task_counter.go | 1 + wafupgradenotice/notes.go | 307 +++++++++++++++ wafupgradenotice/notes_test.go | 125 ++++++ wafupgradenotice/select_test.go | 138 +++++++ wafupgradenotice/upgrade_notes.yaml | 133 +++++++ 18 files changed, 1647 insertions(+), 14 deletions(-) create mode 100644 api/waf_upgrade_notice_api.go create mode 100644 model/request/waf_upgrade_notice_req.go create mode 100644 model/response/waf_upgrade_notice_resp.go create mode 100644 model/upgrade_notice_record.go create mode 100644 router/waf_upgrade_notice_router.go create mode 100644 service/waf_service/cross_engine_upgrade_notice_test.go create mode 100644 service/waf_service/waf_upgrade_notice_service.go create mode 100644 wafupgradenotice/notes.go create mode 100644 wafupgradenotice/notes_test.go create mode 100644 wafupgradenotice/select_test.go create mode 100644 wafupgradenotice/upgrade_notes.yaml diff --git a/api/entrance.go b/api/entrance.go index c2e9cdc..8178fa9 100644 --- a/api/entrance.go +++ b/api/entrance.go @@ -77,6 +77,7 @@ type APIGroup struct { WafAppApi WafAIApi WafUIPreferenceApi + WafUpgradeNoticeApi } var APIGroupAPP = new(APIGroup) @@ -171,4 +172,6 @@ var ( wafAppChangeLogService = waf_service.WafAppChangeLogServiceApp wafUIPreferenceService = waf_service.WafUIPreferenceServiceApp + + wafUpgradeNoticeService = waf_service.WafUpgradeNoticeServiceApp ) diff --git a/api/waf_upgrade_notice_api.go b/api/waf_upgrade_notice_api.go new file mode 100644 index 0000000..e30de75 --- /dev/null +++ b/api/waf_upgrade_notice_api.go @@ -0,0 +1,156 @@ +package api + +import ( + "SamWaf/model" + "SamWaf/model/common/response" + "SamWaf/model/request" + + "github.com/gin-gonic/gin" +) + +type WafUpgradeNoticeApi struct { +} + +// GetSummaryApi 升级须知汇总 +// @Summary 升级须知汇总 +// @Description 首页顶部提示条与登录弹窗用:本次从哪个版本升到哪个版本、还有几项待处理、是否需要弹窗、是否处于降级运行 +// @Tags 升级须知 +// @Produce json +// @Param lang query string false "语言(zh_CN/en_US),默认中文" +// @Success 200 {object} response.Response{data=response.UpgradeNoticeSummary} "获取成功" +// @Security ApiKeyAuth +// @Router /upgradenotice/summary [get] +func (w *WafUpgradeNoticeApi) GetSummaryApi(c *gin.Context) { + var req request.WafUpgradeNoticeSummaryReq + if err := c.ShouldBind(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + response.OkWithDetailed(wafUpgradeNoticeService.GetSummary(req.Lang), "获取成功", c) +} + +// GetListApi 升级须知列表 +// @Summary 升级须知列表 +// @Description 分页查询本次升级带来的须知事项,可按状态/类型/引入版本过滤 +// @Tags 升级须知 +// @Accept json +// @Produce json +// @Param data body request.WafUpgradeNoticeSearchReq true "查询条件" +// @Success 200 {object} response.Response{data=response.PageResult} "获取成功" +// @Security ApiKeyAuth +// @Router /upgradenotice/list [post] +func (w *WafUpgradeNoticeApi) GetListApi(c *gin.Context) { + var req request.WafUpgradeNoticeSearchReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + beans, total, err := wafUpgradeNoticeService.GetListApi(req) + if err != nil { + response.FailWithMessage("查询失败:"+err.Error(), c) + return + } + response.OkWithDetailed(response.PageResult{ + List: beans, + Total: total, + PageIndex: req.PageIndex, + PageSize: req.PageSize, + }, "获取成功", c) +} + +// AckApi 标记为已处理 +// @Summary 升级须知标记已处理 +// @Description 用户点「我知道了」后调用 +// @Tags 升级须知 +// @Accept json +// @Produce json +// @Param data body request.WafUpgradeNoticeIdReq true "条目ID" +// @Success 200 {object} response.Response "操作成功" +// @Security ApiKeyAuth +// @Router /upgradenotice/ack [post] +func (w *WafUpgradeNoticeApi) AckApi(c *gin.Context) { + w.setStatus(c, model.UpgradeNoticeStatusDone) +} + +// IgnoreApi 忽略 +// @Summary 升级须知忽略 +// @Description 忽略后不再计入待处理,仍可在「全部」里查到 +// @Tags 升级须知 +// @Accept json +// @Produce json +// @Param data body request.WafUpgradeNoticeIdReq true "条目ID" +// @Success 200 {object} response.Response "操作成功" +// @Security ApiKeyAuth +// @Router /upgradenotice/ignore [post] +func (w *WafUpgradeNoticeApi) IgnoreApi(c *gin.Context) { + w.setStatus(c, model.UpgradeNoticeStatusIgnored) +} + +// RestoreApi 恢复为待处理 +// @Summary 升级须知恢复待处理 +// @Description 把已处理/已忽略的条目退回待处理 +// @Tags 升级须知 +// @Accept json +// @Produce json +// @Param data body request.WafUpgradeNoticeIdReq true "条目ID" +// @Success 200 {object} response.Response "操作成功" +// @Security ApiKeyAuth +// @Router /upgradenotice/restore [post] +func (w *WafUpgradeNoticeApi) RestoreApi(c *gin.Context) { + w.setStatus(c, model.UpgradeNoticeStatusPending) +} + +func (w *WafUpgradeNoticeApi) setStatus(c *gin.Context, status string) { + var req request.WafUpgradeNoticeIdReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + if err := wafUpgradeNoticeService.SetStatus(req.NoticeId, status, upgradeNoticeOperator(c)); err != nil { + response.FailWithMessage("操作失败:"+err.Error(), c) + return + } + response.OkWithMessage("操作成功", c) +} + +// PopupShownApi 弹窗已展示回写 +// @Summary 升级须知弹窗已展示 +// @Description 登录弹窗关闭后调用,此后不再弹(一辈子只弹一次) +// @Tags 升级须知 +// @Produce json +// @Success 200 {object} response.Response "操作成功" +// @Security ApiKeyAuth +// @Router /upgradenotice/popupshown [post] +func (w *WafUpgradeNoticeApi) PopupShownApi(c *gin.Context) { + if err := wafUpgradeNoticeService.MarkPopupShown(); err != nil { + response.FailWithMessage("操作失败:"+err.Error(), c) + return + } + response.OkWithMessage("操作成功", c) +} + +// DowngradeAckApi 确认降级告警 +// @Summary 确认降级告警 +// @Description 用户点掉首页/列表页顶部的红色降级提示后调用;曾经运行过的最高版本再次变高时告警会重新出现 +// @Tags 升级须知 +// @Produce json +// @Success 200 {object} response.Response "操作成功" +// @Security ApiKeyAuth +// @Router /upgradenotice/downgradeack [post] +func (w *WafUpgradeNoticeApi) DowngradeAckApi(c *gin.Context) { + if err := wafUpgradeNoticeService.AckDowngrade(); err != nil { + response.FailWithMessage("操作失败:"+err.Error(), c) + return + } + response.OkWithMessage("操作成功", c) +} + +// upgradeNoticeOperator 取当前登录账号,用于记录"谁处理的" +func upgradeNoticeOperator(c *gin.Context) string { + if v, ok := c.Get("loginAccount"); ok { + if account, ok := v.(string); ok { + return account + } + } + return "" +} diff --git a/model/request/waf_upgrade_notice_req.go b/model/request/waf_upgrade_notice_req.go new file mode 100644 index 0000000..7dc9919 --- /dev/null +++ b/model/request/waf_upgrade_notice_req.go @@ -0,0 +1,25 @@ +package request + +import "SamWaf/model/common/request" + +// WafUpgradeNoticeSearchReq 升级须知列表查询 +type WafUpgradeNoticeSearchReq struct { + Status string `json:"status" form:"status"` // pending / done / ignored,空=全部 + Kind string `json:"kind" form:"kind"` // notice / action / check,空=全部 + Version string `json:"version" form:"version"` // 按引入版本过滤,空=全部 + Lang string `json:"lang" form:"lang"` // zh_CN / en_US,空=中文 + request.PageInfo +} + +// WafUpgradeNoticeIdReq 只接受条目 id。 +// +// v2 的一键应用同样复用它:配置项名与目标值一律由后端从内置清单取, +// 绝不允许前端传 item/value,否则等于开了一个任意系统配置写入口。 +type WafUpgradeNoticeIdReq struct { + NoticeId string `json:"notice_id" form:"notice_id"` +} + +// WafUpgradeNoticeSummaryReq 顶部提示条 / 弹窗用的汇总 +type WafUpgradeNoticeSummaryReq struct { + Lang string `json:"lang" form:"lang"` +} diff --git a/model/response/waf_upgrade_notice_resp.go b/model/response/waf_upgrade_notice_resp.go new file mode 100644 index 0000000..823e053 --- /dev/null +++ b/model/response/waf_upgrade_notice_resp.go @@ -0,0 +1,45 @@ +package response + +// UpgradeNoticeItem 一条升级须知(文案已按请求语言取好) +type UpgradeNoticeItem struct { + NoticeId string `json:"notice_id"` + Version string `json:"version"` // 引入版本 + Kind string `json:"kind"` // notice / action / check + Level string `json:"level"` // high / normal / low + Status string `json:"status"` // pending / done / ignored + FreshInstall bool `json:"fresh_install"` // 是否属于"全新安装建议" + + Title string `json:"title"` + Detail string `json:"detail"` + EffectOn string `json:"effect_on"` // 做了之后有什么变化 + EffectOff string `json:"effect_off"` // 不做会怎样(必须写代价) + Revert string `json:"revert"` // 怎么撤销 + + Page string `json:"page"` // 「去设置」跳转的站内路径 + Doc string `json:"doc"` // 官方文档链接 + ApplyType string `json:"apply_type"` // none / navigate / config_set + + AppliedTime string `json:"applied_time"` + AppliedUser string `json:"applied_user"` +} + +// UpgradeNoticeSummary 顶部提示条与登录弹窗需要的汇总信息 +type UpgradeNoticeSummary struct { + CurrentVersion string `json:"current_version"` + FromVersion string `json:"from_version"` // 本次升级的起点,空=无历史记录 + ToVersion string `json:"to_version"` + + PendingCount int64 `json:"pending_count"` + HighPendingCount int64 `json:"high_pending_count"` + TotalCount int64 `json:"total_count"` + + // NeedPopup 仅当存在 level=high 且未处理、且从未弹过时为 true。 + // 弹窗一辈子只弹一次,回写靠 /popupshown。 + NeedPopup bool `json:"need_popup"` + PopupItems []UpgradeNoticeItem `json:"popup_items"` + + // Downgrade 旧程序 + 新库(容器重建后回退到镜像自带版本,issue #938)。 + // 过去只打日志、界面完全看不见,这里把它暴露出来。 + Downgrade bool `json:"downgrade"` + DowngradeMsg string `json:"downgrade_msg"` +} diff --git a/model/upgrade_notice_record.go b/model/upgrade_notice_record.go new file mode 100644 index 0000000..080ff90 --- /dev/null +++ b/model/upgrade_notice_record.go @@ -0,0 +1,38 @@ +package model + +import ( + "SamWaf/customtype" + "SamWaf/model/baseorm" +) + +// 升级须知的处理状态 +const ( + UpgradeNoticeStatusPending = "pending" // 待处理 + UpgradeNoticeStatusDone = "done" // 已处理 + UpgradeNoticeStatusIgnored = "ignored" // 已忽略 +) + +// UpgradeNoticeRecord 升级须知的处理状态。 +// +// 只落 notice_id 与状态,**文案不落库** —— 渲染时用当前二进制内置清单里的文案。 +// 这样文案修订随版本走,库里也不会堆冗余文本;代价是清单里被删掉的条目会在库里 +// 留下孤儿记录,列表侧按 notice_id 过滤掉即可。 +type UpgradeNoticeRecord struct { + baseorm.BaseOrm + NoticeId string `gorm:"size:100;index" json:"notice_id"` // 对应内置清单的 id + Version string `gorm:"size:32" json:"version"` // 条目所属版本 + FromVersion string `gorm:"size:32" json:"from_version"` // 本次升级的起点 + ToVersion string `gorm:"size:32" json:"to_version"` // 本次升级的终点 + Kind string `gorm:"size:20" json:"kind"` // notice / action / check + Level string `gorm:"size:20" json:"level"` // high / normal / low + Status string `gorm:"size:20;index" json:"status"` // pending / done / ignored + OldValue string `gorm:"type:text" json:"old_value"` // v2 一键应用前的原值,供撤销 + AppliedTime customtype.JsonTime `json:"applied_time"` // 处理时间 + AppliedUser string `gorm:"size:64" json:"applied_user"` // 处理人 + PopupShown int `json:"popup_shown"` // 1=已弹过窗,此后不再弹 +} + +// TableName 表名 +func (UpgradeNoticeRecord) TableName() string { + return "upgrade_notice_record" +} diff --git a/router/entrance.go b/router/entrance.go index ce06e87..d35a544 100644 --- a/router/entrance.go +++ b/router/entrance.go @@ -74,6 +74,7 @@ type ApiGroup struct { WafAppRouter WafAIRouter WafUIPreferenceRouter + UpgradeNoticeRouter } type PublicApiGroup struct { LoginRouter diff --git a/router/waf_upgrade_notice_router.go b/router/waf_upgrade_notice_router.go new file mode 100644 index 0000000..9ab1c1d --- /dev/null +++ b/router/waf_upgrade_notice_router.go @@ -0,0 +1,28 @@ +package router + +import ( + "SamWaf/api" + + "github.com/gin-gonic/gin" +) + +type UpgradeNoticeRouter struct { +} + +// InitUpgradeNoticeRouter 升级须知:升级后告知本次变更与建议操作 +// +// 只读查询 + 本机处理状态标记,不改任何防护策略与系统配置, +// 因此挂在"任意已登录角色"组(首页提示条对所有角色都要可见)。 +// v2 的一键写配置属于系统管理员域,届时单独开一个受 RequireRole 保护的路由组。 +func (receiver *UpgradeNoticeRouter) InitUpgradeNoticeRouter(group *gin.RouterGroup) { + upgradeNoticeApi := api.APIGroupAPP.WafUpgradeNoticeApi + router := group.Group("") + + router.GET("/api/v1/upgradenotice/summary", upgradeNoticeApi.GetSummaryApi) // 顶部提示条/弹窗汇总 + router.POST("/api/v1/upgradenotice/list", upgradeNoticeApi.GetListApi) // 列表 + router.POST("/api/v1/upgradenotice/ack", upgradeNoticeApi.AckApi) // 我知道了 + router.POST("/api/v1/upgradenotice/ignore", upgradeNoticeApi.IgnoreApi) // 忽略 + router.POST("/api/v1/upgradenotice/restore", upgradeNoticeApi.RestoreApi) // 恢复待处理 + router.POST("/api/v1/upgradenotice/popupshown", upgradeNoticeApi.PopupShownApi) // 弹窗已展示 + router.POST("/api/v1/upgradenotice/downgradeack", upgradeNoticeApi.DowngradeAckApi) // 确认降级告警 +} diff --git a/service/waf_service/cross_engine_core_test.go b/service/waf_service/cross_engine_core_test.go index ca38bde..0c5b15d 100644 --- a/service/waf_service/cross_engine_core_test.go +++ b/service/waf_service/cross_engine_core_test.go @@ -32,6 +32,8 @@ func runCoreCases(t *testing.T, db *gorm.DB) { // ————— 新增:各 service 完整 CRUD 回路 ————— runCoreCRUDCases(t, db) runCoreCRUDCasesB(t, db) + // ————— 升级须知:生成幂等 / 状态流转 / 降级不生成 ————— + runUpgradeNoticeCases(t, db) } // ========================= 修改类(保留原有覆盖)========================= diff --git a/service/waf_service/cross_engine_upgrade_notice_test.go b/service/waf_service/cross_engine_upgrade_notice_test.go new file mode 100644 index 0000000..a0f6621 --- /dev/null +++ b/service/waf_service/cross_engine_upgrade_notice_test.go @@ -0,0 +1,200 @@ +//go:build crossdb + +// 升级须知(upgrade_notice_record)三库回归:生成幂等、状态流转、列表过滤、降级不生成。 +package waf_service + +import ( + "SamWaf/model" + commonreq "SamWaf/model/common/request" + "SamWaf/model/request" + "SamWaf/wafupgradenotice" + "strings" + "testing" + + "gorm.io/gorm" +) + +func runUpgradeNoticeCases(t *testing.T, db *gorm.DB) { + svc := WafUpgradeNoticeServiceApp + + clean := func() { + must(t, db.Where("1 = 1").Delete(&model.UpgradeNoticeRecord{}).Error) + } + + countRecords := func() int64 { + var n int64 + must(t, db.Model(&model.UpgradeNoticeRecord{}).Count(&n).Error) + return n + } + + t.Run("UpgradeNotice_生成与幂等", func(t *testing.T) { + clean() + // v1.3.21 -> v1.3.23:应当取到内置清单里 (v1.3.21, v1.3.23] 区间的条目 + want := len(wafupgradenotice.Select("v1.3.21", "v1.3.23", false)) + if want == 0 { + t.Skip("内置清单在该区间没有条目") + } + svc.Generate("v1.3.21", "v1.3.23", "v1.3.21") + if got := countRecords(); got != int64(want) { + t.Fatalf("首次生成条数不对: got %d want %d", got, want) + } + // 再跑一次(模拟重启)不得重复生成 + svc.Generate("v1.3.21", "v1.3.23", "v1.3.21") + if got := countRecords(); got != int64(want) { + t.Fatalf("重复启动产生了重复记录: got %d want %d", got, want) + } + }) + + t.Run("UpgradeNotice_状态流转不被再次生成覆盖", func(t *testing.T) { + clean() + notes := wafupgradenotice.Select("v1.3.21", "v1.3.23", false) + if len(notes) == 0 { + t.Skip("内置清单在该区间没有条目") + } + svc.Generate("v1.3.21", "v1.3.23", "v1.3.21") + + target := notes[0].Id + fatalIf(t, svc.SetStatus(target, model.UpgradeNoticeStatusDone, "tester")) + + var bean model.UpgradeNoticeRecord + firstBy(t, db, &bean, "notice_id = ?", target) + if bean.Status != model.UpgradeNoticeStatusDone || bean.AppliedUser != "tester" { + t.Fatalf("状态未落库: %+v", bean) + } + + // 重新生成不得把已处理改回待处理 + svc.Generate("v1.3.21", "v1.3.23", "v1.3.21") + firstBy(t, db, &bean, "notice_id = ?", target) + if bean.Status != model.UpgradeNoticeStatusDone { + t.Fatalf("重复生成覆盖了用户的处理状态: %+v", bean) + } + + // 恢复待处理时要把处理人/处理时间清掉 + fatalIf(t, svc.SetStatus(target, model.UpgradeNoticeStatusPending, "tester")) + firstBy(t, db, &bean, "notice_id = ?", target) + if bean.Status != model.UpgradeNoticeStatusPending || bean.AppliedUser != "" { + t.Fatalf("恢复待处理未清理处理人: %+v", bean) + } + }) + + t.Run("UpgradeNotice_伪造ID被拒", func(t *testing.T) { + if err := svc.SetStatus("not_in_manifest", model.UpgradeNoticeStatusDone, "tester"); err == nil { + t.Fatal("清单外的 notice_id 应当被拒绝") + } + if err := svc.SetStatus("", model.UpgradeNoticeStatusDone, "tester"); err == nil { + t.Fatal("空 notice_id 应当被拒绝") + } + }) + + t.Run("UpgradeNotice_列表与汇总", func(t *testing.T) { + clean() + notes := wafupgradenotice.Select("v1.3.21", "v1.3.23", false) + if len(notes) == 0 { + t.Skip("内置清单在该区间没有条目") + } + svc.Generate("v1.3.21", "v1.3.23", "v1.3.21") + + items, total, err := svc.GetListApi(request.WafUpgradeNoticeSearchReq{ + Status: model.UpgradeNoticeStatusPending, + Lang: "zh_CN", + PageInfo: commonreq.PageInfo{PageIndex: 1, PageSize: 50}, + }) + fatalIf(t, err) + if total != int64(len(notes)) || len(items) != len(notes) { + t.Fatalf("列表条数不对: total=%d items=%d want=%d", total, len(items), len(notes)) + } + for _, item := range items { + if item.Title == "" { + t.Fatalf("条目文案为空(清单未接上): %+v", item) + } + } + + // 英文必须给出另一套文案 + enItems, _, err := svc.GetListApi(request.WafUpgradeNoticeSearchReq{ + Status: model.UpgradeNoticeStatusPending, + Lang: "en_US", + PageInfo: commonreq.PageInfo{PageIndex: 1, PageSize: 50}, + }) + fatalIf(t, err) + if len(enItems) != len(items) || enItems[0].Title == items[0].Title { + t.Fatalf("英文文案未生效: zh=%q en=%q", items[0].Title, enItems[0].Title) + } + + summary := svc.GetSummary("zh_CN") + if summary.PendingCount != int64(len(notes)) { + t.Fatalf("汇总待处理数不对: %d want %d", summary.PendingCount, len(notes)) + } + if summary.HighPendingCount > 0 && !summary.NeedPopup { + t.Fatal("存在未弹过窗的重要条目时应当需要弹窗") + } + + // 弹过一次之后不再弹 + fatalIf(t, svc.MarkPopupShown()) + if svc.GetSummary("zh_CN").NeedPopup { + t.Fatal("弹窗回写后不应再要求弹窗") + } + }) + + t.Run("UpgradeNotice_降级不生成只给告警", func(t *testing.T) { + clean() + svc.Generate("v1.3.24", "v1.3.22", "v1.3.24") + if got := countRecords(); got != 0 { + t.Fatalf("降级路径不应生成须知,实际生成了 %d 条", got) + } + summary := svc.GetSummary("zh_CN") + if !summary.Downgrade || summary.DowngradeMsg == "" { + t.Fatalf("降级运行应当在汇总里给出告警: %+v", summary) + } + // 恢复正常状态,避免影响后续用例 + svc.Generate("v1.3.23", "v1.3.23", "v1.3.23") + }) + + // 降级判定必须走"历史最高版本":last 会被写回成当前这个较低版本, + // 用 last 判的话重启一次告警就消失,而旧程序+新库的状态还在。 + t.Run("UpgradeNotice_降级判定用最高版本而非上次版本", func(t *testing.T) { + clean() + // 模拟"降级后又重启一次":last 已经被上一轮写成了当前的低版本,只有 maxSeen 还记得高版本 + svc.Generate("v1.3.22", "v1.3.22", "v1.3.24") + summary := svc.GetSummary("zh_CN") + if !summary.Downgrade { + t.Fatal("重启后仍应保留降级告警(证据在 max_run_version 里)") + } + if !strings.Contains(summary.DowngradeMsg, "v1.3.24") { + t.Fatalf("告警应指向历史最高版本 v1.3.24: %s", summary.DowngradeMsg) + } + svc.Generate("v1.3.23", "v1.3.23", "v1.3.23") + }) + + t.Run("UpgradeNotice_降级告警确认与复现", func(t *testing.T) { + clean() + must(t, db.Where("item = ?", downgradeAckItem).Delete(&model.SystemConfig{}).Error) + + svc.Generate("v1.3.22", "v1.3.22", "v1.3.24") + if !svc.GetSummary("zh_CN").Downgrade { + t.Fatal("确认前应有降级告警") + } + fatalIf(t, svc.AckDowngrade()) + if svc.GetSummary("zh_CN").Downgrade { + t.Fatal("确认后不应再显示降级告警") + } + + // 同一个历史最高版本重启多少次都不再提示 + svc.Generate("v1.3.22", "v1.3.22", "v1.3.24") + if svc.GetSummary("zh_CN").Downgrade { + t.Fatal("同一最高版本重启后不应重新提示") + } + + // 最高版本又变高 = 用户又升级又回退了一次,是新证据,告警必须重新出现 + svc.Generate("v1.3.22", "v1.3.22", "v1.3.25") + if !svc.GetSummary("zh_CN").Downgrade { + t.Fatal("历史最高版本变高后应重新提示") + } + + // 没有告警时确认应当被拒绝 + svc.Generate("v1.3.23", "v1.3.23", "v1.3.23") + if err := svc.AckDowngrade(); err == nil { + t.Fatal("没有降级告警时不应允许确认") + } + must(t, db.Where("item = ?", downgradeAckItem).Delete(&model.SystemConfig{}).Error) + }) +} diff --git a/service/waf_service/waf_upgrade_notice_service.go b/service/waf_service/waf_upgrade_notice_service.go new file mode 100644 index 0000000..cc58129 --- /dev/null +++ b/service/waf_service/waf_upgrade_notice_service.go @@ -0,0 +1,368 @@ +package waf_service + +import ( + "SamWaf/common/uuid" + "SamWaf/common/zlog" + "SamWaf/customtype" + "SamWaf/global" + "SamWaf/model" + "SamWaf/model/baseorm" + "SamWaf/model/request" + "SamWaf/model/response" + "SamWaf/wafupgradenotice" + "errors" + "strings" + "sync" + "time" + + "golang.org/x/mod/semver" +) + +// 升级须知:升级完成后在产品内告诉用户"这次变了什么、建议点哪个开关、点了和不点有什么区别"。 +// +// 数据源是随版本编译进二进制的内置清单(wafupgradenotice),库里只存 notice_id 与处理状态, +// 文案渲染时现取——文案修订随版本走,库里也不留冗余文本。 +type WafUpgradeNoticeService struct{} + +var WafUpgradeNoticeServiceApp = new(WafUpgradeNoticeService) + +// downgradeAckItem 记录用户已确认过的降级告警对应的"历史最高版本"。 +// 只有 max_run_version 再次变高(说明又升级又回退了一次,是新证据)时告警才会重新出现。 +const downgradeAckItem = "downgrade_notice_ack" + +// 启动时算出来的一次性状态:本次是从哪个版本升到哪个版本、是否在降级运行 +var ( + upgradeStateMu sync.RWMutex + upgradeFromVersion string + upgradeToVersion string + downgradeMessage string + downgradeMaxSeen string // 触发本次降级告警的历史最高版本,确认告警时记它 +) + +// Generate 按"上次运行版本 → 当前版本"生成本次应当提示的须知。 +// +// 由 main 在 CheckVersionDowngrade 之后调用(那个函数读完 last_run_version 会立刻写回当前版本, +// 所以旧版本号必须由它交出来,不能在这里再查一次)。 +// +// last 为空 + 库里没有任何站点 → 认作全新安装,只给"全新安装建议" +// last 为空 + 库里已有站点 → 老实例首次升到带本功能的版本,起点未知,只给当前这一版的条目 +// last < current → 给 (last, current] 区间内的条目 +// last >= current → 不生成;降级时另记一条界面可见的告警 +// +// maxSeen 是"曾经运行过的最高版本"(只升不降),**降级判定用它而不是 last**: +// last 会被写回成当前这个较低的版本,用它判定的话重启一次告警就消失了, +// 而"旧程序 + 新库"的状态还在(见 waftask.CheckVersionDowngrade 的说明)。 +// +// 任何失败都只记日志,绝不阻断启动。 +func (receiver *WafUpgradeNoticeService) Generate(last, current, maxSeen string) { + if global.GWAF_LOCAL_DB == nil { + return + } + last = strings.TrimSpace(last) + current = strings.TrimSpace(current) + maxSeen = strings.TrimSpace(maxSeen) + if maxSeen == "" { + maxSeen = last + } + + upgradeStateMu.Lock() + // 同版本重启时不报"从 vX 升级到 vX":留空让界面改用"当前版本 vX"的说法。 + // 上次升级遗留的未处理条目仍在,提示条也还会出现,只是不再声称刚发生过一次升级。 + upgradeFromVersion = last + if last == current { + upgradeFromVersion = "" + } + upgradeToVersion = current + downgradeMessage = "" + downgradeMaxSeen = "" + if maxSeen != "" && semver.IsValid(maxSeen) && semver.IsValid(current) && semver.Compare(maxSeen, current) > 0 { + downgradeMaxSeen = maxSeen + downgradeMessage = "检测到降级运行:数据库记录曾经运行过的最高版本是 " + maxSeen + ",当前程序版本是 " + current + + "。若为容器部署,通常是应用内升级后容器被重建、程序回退到镜像自带版本所致;数据库已按新版本迁移且无法回退,请把镜像更新到 " + maxSeen + " 或更高版本后重建容器" + } + upgradeStateMu.Unlock() + + if err := wafupgradenotice.LoadError(); err != nil { + zlog.Warn("升级须知内置清单解析失败", "error", err.Error()) + return + } + + notes := wafupgradenotice.Select(last, current, receiver.isFreshInstall()) + if len(notes) == 0 { + return + } + + created := 0 + for _, note := range notes { + var count int64 + if err := global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}). + Where("notice_id = ?", note.Id).Count(&count).Error; err != nil { + zlog.Debug("查询升级须知记录失败", err.Error()) + continue + } + // 已生成过就跳过:不覆盖用户已有的处理状态 + if count > 0 { + continue + } + bean := model.UpgradeNoticeRecord{ + BaseOrm: baseorm.BaseOrm{ + Id: uuid.GenUUID(), + USER_CODE: global.GWAF_USER_CODE, + Tenant_ID: global.GWAF_TENANT_ID, + CREATE_TIME: customtype.JsonTime(time.Now()), + UPDATE_TIME: customtype.JsonTime(time.Now()), + }, + NoticeId: note.Id, + Version: note.Version, + FromVersion: last, + ToVersion: current, + Kind: note.Kind, + Level: note.Level, + Status: model.UpgradeNoticeStatusPending, + } + if err := global.GWAF_LOCAL_DB.Create(&bean).Error; err != nil { + zlog.Debug("写入升级须知记录失败", err.Error()) + continue + } + created++ + } + if created > 0 { + zlog.Info("升级须知已生成", "from", last, "to", current, "count", created) + } +} + +// isFreshInstall 判断是不是全新安装。 +// +// 没有站点 = 这个实例还没被用起来。存量用户首次升到带本功能的版本时 last_run_version 同样为空, +// 靠这个信号把两者分开,避免给老用户倒一堆"改初始密码"之类的新手提示。 +func (receiver *WafUpgradeNoticeService) isFreshInstall() bool { + var hostCount int64 + if err := global.GWAF_LOCAL_DB.Model(&model.Hosts{}).Count(&hostCount).Error; err != nil { + return false + } + return hostCount == 0 +} + +// GetListApi 分页查询升级须知 +func (receiver *WafUpgradeNoticeService) GetListApi(req request.WafUpgradeNoticeSearchReq) ([]response.UpgradeNoticeItem, int64, error) { + if global.GWAF_LOCAL_DB == nil { + return nil, 0, errors.New("数据库未就绪") + } + known := knownNoticeIds() + if len(known) == 0 { + return []response.UpgradeNoticeItem{}, 0, nil + } + + // 清单里已删除的条目会在库里留下孤儿记录,用 IN 直接排除,保证总数与分页都对得上 + tx := global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).Where("notice_id in ?", known) + if req.Status != "" { + tx = tx.Where("status = ?", req.Status) + } + if req.Kind != "" { + tx = tx.Where("kind = ?", req.Kind) + } + if req.Version != "" { + // 前端传的是归一化后的展示版本(v1.3.24),库里存的是原始值(可能是 v1.3.24-beta.15) + tx = tx.Where("version in ?", wafupgradenotice.RawVersionsFor(req.Version)) + } + + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + + pageIndex := req.PageIndex + if pageIndex <= 0 { + pageIndex = 1 + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 10 + } + + var beans []model.UpgradeNoticeRecord + // 重要的排前面,同级按引入版本倒序(新版本的事更要紧) + if err := tx.Order("case level when 'high' then 0 when 'normal' then 1 else 2 end, version desc, create_time desc"). + Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&beans).Error; err != nil { + return nil, 0, err + } + + items := make([]response.UpgradeNoticeItem, 0, len(beans)) + for _, bean := range beans { + if item, ok := toItem(bean, req.Lang); ok { + items = append(items, item) + } + } + return items, total, nil +} + +// GetSummary 顶部提示条与登录弹窗需要的汇总 +func (receiver *WafUpgradeNoticeService) GetSummary(lang string) response.UpgradeNoticeSummary { + upgradeStateMu.RLock() + summary := response.UpgradeNoticeSummary{ + CurrentVersion: global.GWAF_RELEASE_VERSION, + FromVersion: upgradeFromVersion, + ToVersion: upgradeToVersion, + Downgrade: downgradeMessage != "", + DowngradeMsg: downgradeMessage, + } + maxSeen := downgradeMaxSeen + upgradeStateMu.RUnlock() + + // 用户确认过同一个"历史最高版本"就不再显示;max 再变高说明是新证据,告警重新出现 + if summary.Downgrade && WafSystemConfigServiceApp.GetDetailByItem(downgradeAckItem).Value == maxSeen { + summary.Downgrade = false + summary.DowngradeMsg = "" + } + + if global.GWAF_LOCAL_DB == nil { + return summary + } + known := knownNoticeIds() + if len(known) == 0 { + return summary + } + + global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}). + Where("notice_id in ?", known).Count(&summary.TotalCount) + global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}). + Where("notice_id in ? and status = ?", known, model.UpgradeNoticeStatusPending). + Count(&summary.PendingCount) + + var highPending []model.UpgradeNoticeRecord + global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}). + Where("notice_id in ? and status = ? and level = ?", known, model.UpgradeNoticeStatusPending, wafupgradenotice.LevelHigh). + Order("version desc").Find(&highPending) + summary.HighPendingCount = int64(len(highPending)) + + for _, bean := range highPending { + item, ok := toItem(bean, lang) + if !ok { + continue + } + summary.PopupItems = append(summary.PopupItems, item) + // 只要有一条重要须知还没弹过窗,本次登录就弹一次 + if bean.PopupShown == 0 { + summary.NeedPopup = true + } + } + return summary +} + +// SetStatus 标记处理状态(我知道了 / 忽略 / 恢复待处理) +func (receiver *WafUpgradeNoticeService) SetStatus(noticeId, status, operator string) error { + if global.GWAF_LOCAL_DB == nil { + return errors.New("数据库未就绪") + } + noticeId = strings.TrimSpace(noticeId) + if noticeId == "" { + return errors.New("条目不能为空") + } + // 只认内置清单里存在的条目,杜绝伪造 id 往表里写状态 + if _, ok := wafupgradenotice.ByID(noticeId); !ok { + return errors.New("条目不存在") + } + switch status { + case model.UpgradeNoticeStatusPending, model.UpgradeNoticeStatusDone, model.UpgradeNoticeStatusIgnored: + default: + return errors.New("状态非法") + } + + updates := map[string]interface{}{ + "status": status, + "UPDATE_TIME": customtype.JsonTime(time.Now()), + } + if status == model.UpgradeNoticeStatusPending { + updates["applied_user"] = "" + updates["applied_time"] = nil + } else { + updates["applied_user"] = operator + updates["applied_time"] = customtype.JsonTime(time.Now()) + } + return global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}). + Where("notice_id = ?", noticeId).Updates(updates).Error +} + +// MarkPopupShown 弹窗关闭后回写:此后不再弹,靠首页提示条和列表页兜底 +func (receiver *WafUpgradeNoticeService) MarkPopupShown() error { + if global.GWAF_LOCAL_DB == nil { + return errors.New("数据库未就绪") + } + return global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}). + Where("popup_shown = ?", 0). + Updates(map[string]interface{}{ + "popup_shown": 1, + "UPDATE_TIME": customtype.JsonTime(time.Now()), + }).Error +} + +// AckDowngrade 确认降级告警:记下当前这个"历史最高版本",此后不再显示。 +// +// 不做成"永久关闭":只要 max_run_version 再次变高(用户又升级又回退了一次), +// 就是新的证据,告警会重新出现。 +func (receiver *WafUpgradeNoticeService) AckDowngrade() error { + upgradeStateMu.RLock() + maxSeen := downgradeMaxSeen + hasWarning := downgradeMessage != "" + upgradeStateMu.RUnlock() + + if !hasWarning || maxSeen == "" { + return errors.New("当前没有降级告警") + } + + existing := WafSystemConfigServiceApp.GetDetailByItem(downgradeAckItem) + if existing.Id != "" { + return WafSystemConfigServiceApp.ModifyByItemApi(request.WafSystemConfigEditByItemReq{ + Item: downgradeAckItem, + Value: maxSeen, + }) + } + return WafSystemConfigServiceApp.AddApi(request.WafSystemConfigAddReq{ + ItemClass: "system", + Item: downgradeAckItem, + Value: maxSeen, + Remarks: "已确认过的降级告警对应的历史最高版本;曾经运行过的最高版本再次变高时告警会重新出现,请勿手工修改", + ItemType: "string", + }) +} + +// knownNoticeIds 当前二进制内置清单里的全部条目 id +func knownNoticeIds() []string { + notes := wafupgradenotice.All() + ids := make([]string, 0, len(notes)) + for _, n := range notes { + ids = append(ids, n.Id) + } + return ids +} + +// toItem 把库记录 + 内置清单文案拼成一条返回项;清单里已没有的条目返回 false 由调用方丢弃 +func toItem(bean model.UpgradeNoticeRecord, lang string) (response.UpgradeNoticeItem, bool) { + note, ok := wafupgradenotice.ByID(bean.NoticeId) + if !ok { + zlog.Debug("升级须知记录在当前版本清单中已不存在,跳过", "notice_id", bean.NoticeId) + return response.UpgradeNoticeItem{}, false + } + text := note.Text(lang) + item := response.UpgradeNoticeItem{ + NoticeId: note.Id, + Version: wafupgradenotice.DisplayVersion(note.Version), + Kind: note.Kind, + Level: note.Level, + Status: bean.Status, + FreshInstall: note.FreshInstall, + Title: text.Title, + Detail: text.Detail, + EffectOn: text.EffectOn, + EffectOff: text.EffectOff, + Revert: text.Revert, + Page: note.Page, + Doc: note.Doc, + ApplyType: note.Apply.Type, + AppliedUser: bean.AppliedUser, + } + if applied := time.Time(bean.AppliedTime); !applied.IsZero() { + item.AppliedTime = applied.Format("2006-01-02 15:04:05") + } + return item, true +} diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index 8c3f571..cf4aef5 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -1920,6 +1920,26 @@ func RunCoreDBMigrations(db *gorm.DB) error { return nil }, }, + // 迁移: 创建升级须知记录表(升级后在产品内提示本次变更与建议操作) + { + ID: "202608220001_add_upgrade_notice_record_table", + Migrate: func(tx *gorm.DB) error { + zlog.Info("迁移 202608220001: 创建升级须知记录表") + if err := tx.AutoMigrate(&model.UpgradeNoticeRecord{}); err != nil { + return fmt.Errorf("创建升级须知记录表失败: %w", err) + } + // notice_id 唯一:同一条须知跨多次重启只生成一次,用户处理过的状态不会被覆盖 + if err := safeCreateIndex(tx, "upgrade_notice_record", "uni_upgrade_notice_id", + "CREATE UNIQUE INDEX IF NOT EXISTS uni_upgrade_notice_id ON upgrade_notice_record(notice_id, user_code, tenant_id)"); err != nil { + zlog.Warn("创建索引 uni_upgrade_notice_id 失败", "error", err.Error()) + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + zlog.Info("回滚 202608220001: 删除升级须知记录表") + return tx.Migrator().DropTable(&model.UpgradeNoticeRecord{}) + }, + }, }) // 执行迁移 diff --git a/wafmangeweb/localserver.go b/wafmangeweb/localserver.go index df9b531..0885cc5 100644 --- a/wafmangeweb/localserver.go +++ b/wafmangeweb/localserver.go @@ -177,6 +177,9 @@ func (web *WafWebManager) initRouter(r *gin.Engine) { // 界面偏好:按登录账号隔离(账号归属即授权边界),任意已登录角色均可存取本人的界面偏好 router.ApiGroupApp.InitWafUIPreferenceRouter(RouterGroup) + // 升级须知:只读查询 + 本机处理状态标记,首页提示条对所有角色都要可见 + router.ApiGroupApp.InitUpgradeNoticeRouter(RouterGroup) + // 自助改密:任意已登录角色均可修改本人密码(含首次登录/到期强制改密场景),不受角色域限制 RouterGroup.POST("/api/v1/account/changemypwd", api.APIGroupAPP.WafAccountApi.ChangeMyPasswordApi) diff --git a/waftask/task_config.go b/waftask/task_config.go index be93dbb..002bd9c 100644 --- a/waftask/task_config.go +++ b/waftask/task_config.go @@ -607,12 +607,23 @@ func TaskLoadSettingCron() { TaskLoadSetting(false) } -// CheckVersionDowngrade 记录/校验"上次运行的版本",发现降级运行时打日志。 +// CheckVersionDowngrade 维护两个版本水位并做降级校验,顺带生成本次的「升级须知」。 // // 场景:容器环境点了应用内升级后,新版本会把数据库迁移到新 schema,但程序文件在镜像可写层, // 容器一旦重建就回退成镜像自带的旧版本,而数据库回不去 —— 形成"旧程序 + 新库"。 // 这种状态过去是完全静默的,只能靠"任务方法 xxx 未找到"这类间接报错才能猜出来(issue #938)。 -// 这里只记日志、不阻断启动,也不做界面提示。 +// 这里只记日志、不阻断启动。 +// +// 为什么要两个配置项: +// - last_run_version 上次运行的版本,**跟着当前版本走(会下调)**,用于算升级须知的版本区间 +// - max_run_version 曾经运行过的最高版本,**只升不降**,用于降级判定 +// +// 降级判定必须用后者。若沿用 last_run_version,判完降级后它会被改写成当前这个较低的版本, +// 等于自己把证据抹掉:重启一次警告就永远消失,而"旧程序 + 新库"的真实状态还在。 +// 容器反复重建恰恰是这个警告要防的场景,只警告第一次等于没警告。 +// +// 升级须知刻意写在这个函数里、而不是另找地方再查一次 last_run_version: +// 本函数读完就会把库里的值改写成当前版本,一旦写回,旧版本号就再也拿不到了。 func CheckVersionDowngrade() { current := strings.TrimSpace(global.GWAF_RELEASE_VERSION) if current == "" { @@ -624,39 +635,68 @@ func CheckVersionDowngrade() { last = strings.TrimSpace(item.Value) } - if last != "" && semver.IsValid(last) && semver.IsValid(current) && semver.Compare(last, current) > 0 { - zlog.Error(fmt.Sprintf("检测到降级运行:数据库记录的上次运行版本是 %v,当前程序版本是 %v。"+ + maxItem := wafSystemConfigService.GetDetailByItem(maxVersionRecordItem) + maxSeen := "" + if maxItem.Id != "" { + maxSeen = strings.TrimSpace(maxItem.Value) + } + // 老库没有 max_run_version 这一行,用 last_run_version 当初值: + // 升级到本版本之前跑过的最高版本,最好的已知近似就是上次运行的那个。 + if maxSeen == "" { + maxSeen = last + } + + if maxSeen != "" && semver.IsValid(maxSeen) && semver.IsValid(current) && semver.Compare(maxSeen, current) > 0 { + zlog.Error(fmt.Sprintf("检测到降级运行:数据库记录曾经运行过的最高版本是 %v,当前程序版本是 %v。"+ "若为容器部署,通常是应用内升级后容器被重建、程序回退到镜像自带版本所致;"+ - "数据库已按新版本迁移且无法回退,请把镜像更新到 %v 或更高版本后重建容器", last, current, last)) + "数据库已按新版本迁移且无法回退,请把镜像更新到 %v 或更高版本后重建容器", maxSeen, current, maxSeen)) + } + + // 生成本次升级须知(内置清单 + 版本区间)。必须赶在下面写回 last_run_version 之前。 + wafUpgradeNoticeSvc.Generate(last, current, maxSeen) + + // 版本水位只升不降:降级运行时保留历史最高版本,警告才不会因为一次重启就消失 + if maxSeen == "" || (semver.IsValid(current) && (!semver.IsValid(maxSeen) || semver.Compare(current, maxSeen) > 0)) { + writeVersionRecord(maxItem, maxVersionRecordItem, current, + "曾经成功启动过的最高程序版本(只升不降),用于识别\"程序被降级、数据库却已按新版本迁移\"的不一致状态,请勿手工修改") } // 记录本次版本(只在变化时写库) if last == current { return } - if item.Id != "" { + writeVersionRecord(item, versionRecordItem, current, + "上次成功启动的程序版本,用于计算升级须知的版本区间,请勿手工修改") +} + +// writeVersionRecord 写入/更新一个版本水位配置项。失败只记日志,不影响启动。 +func writeVersionRecord(existing model.SystemConfig, item, value, remarks string) { + if existing.Id != "" { if err := wafSystemConfigService.ModifyByItemApi(request.WafSystemConfigEditByItemReq{ - Item: versionRecordItem, - Value: current, + Item: item, + Value: value, }); err != nil { - zlog.Debug("记录本次运行版本失败", err.Error()) + zlog.Debug("更新版本记录失败", item, err.Error()) } return } if err := wafSystemConfigService.AddApi(request.WafSystemConfigAddReq{ ItemClass: "system", - Item: versionRecordItem, - Value: current, - Remarks: "上次成功启动的程序版本,用于识别\"程序被降级、数据库却已按新版本迁移\"的不一致状态,请勿手工修改", + Item: item, + Value: value, + Remarks: remarks, ItemType: "string", }); err != nil { - zlog.Debug("写入本次运行版本失败", err.Error()) + zlog.Debug("写入版本记录失败", item, err.Error()) } } -// versionRecordItem 记录"上次运行版本"的配置项名 +// versionRecordItem 记录"上次运行版本"的配置项名(跟着当前版本走,可下调) const versionRecordItem = "last_run_version" +// maxVersionRecordItem 记录"曾经运行过的最高版本"的配置项名(只升不降,降级判定用它) +const maxVersionRecordItem = "max_run_version" + // TaskLoadSetting 加载配置数据 // // initLoad true 是初始化加载,false不是初始化加载 diff --git a/waftask/task_counter.go b/waftask/task_counter.go index 77a5aa8..915c6f7 100644 --- a/waftask/task_counter.go +++ b/waftask/task_counter.go @@ -8,6 +8,7 @@ var ( wafSysLogService = waf_service.WafSysLogServiceApp wafSystemConfigService = waf_service.WafSystemConfigServiceApp wafLogService = waf_service.WafLogServiceApp + wafUpgradeNoticeSvc = waf_service.WafUpgradeNoticeServiceApp ) type LastCounter struct { diff --git a/wafupgradenotice/notes.go b/wafupgradenotice/notes.go new file mode 100644 index 0000000..4b81f18 --- /dev/null +++ b/wafupgradenotice/notes.go @@ -0,0 +1,307 @@ +// Package wafupgradenotice 提供「升级须知」的内置清单:解析、校验与按版本区间挑选。 +// +// 清单随版本编译进二进制(见 upgrade_notes.yaml),不走网络: +// 内网/离线部署升完级同样能看到,也不新增任何对外请求。 +// 本包是纯函数层,不碰数据库——落库与状态流转在 service/waf_service。 +package wafupgradenotice + +import ( + _ "embed" + "fmt" + "regexp" + "strings" + "sync" + + "golang.org/x/mod/semver" + "gopkg.in/yaml.v3" +) + +//go:embed upgrade_notes.yaml +var notesYAML []byte + +// 条目类型 +const ( + KindNotice = "notice" // 知悉:行为变了,用户无需动作 + KindAction = "action" // 建议操作:建议开/关某个能力 + KindCheck = "check" // 场景确认:只有部分用户受影响,需自查 +) + +// 重要程度:只有 high 会在登录后弹一次窗 +const ( + LevelHigh = "high" + LevelNormal = "normal" + LevelLow = "low" +) + +// 执行方式 +const ( + ApplyNone = "none" // 无可执行动作 + ApplyNavigate = "navigate" // 跳到对应页面由用户自己操作 + ApplyConfigSet = "config_set" // v2:一键写配置(前端只传 id,item/value 由后端从本清单取) +) + +// docPrefix 文档链接只允许指向官方文档站,防止清单将来接云端补丁时被用作跳板 +const docPrefix = "https://doc.samwaf.com/" + +// idPattern 条目 id 只允许小写字母、数字与下划线 +var idPattern = regexp.MustCompile(`^[a-z0-9_]+$`) + +// Text 一条须知的单语言文案 +type Text struct { + Title string `yaml:"title"` + Detail string `yaml:"detail"` + EffectOn string `yaml:"effect_on"` + EffectOff string `yaml:"effect_off"` + Revert string `yaml:"revert"` +} + +// Apply 建议操作的执行方式 +type Apply struct { + Type string `yaml:"type"` + Item string `yaml:"item"` + Value string `yaml:"value"` +} + +// Note 内置清单里的一条须知 +type Note struct { + Id string `yaml:"id"` // 全局唯一,幂等键,一旦发布不得修改 + Version string `yaml:"version"` // 哪个版本引入(semver,参与区间计算) + Kind string `yaml:"kind"` // notice / action / check + Level string `yaml:"level"` // high / normal / low + FreshInstall bool `yaml:"fresh_install"` // true 表示这是"全新安装建议",只在无历史版本时生成 + Page string `yaml:"page"` // 前端路由,「去设置」跳这里;只允许站内相对路径 + Doc string `yaml:"doc"` // 官方文档链接 + Apply Apply `yaml:"apply"` + ZH Text `yaml:"zh"` + EN Text `yaml:"en"` +} + +// Text 按语言取文案,非 en 一律回落中文 +func (n Note) Text(lang string) Text { + if strings.HasPrefix(strings.ToLower(lang), "en") { + return n.EN + } + return n.ZH +} + +type noteFile struct { + Notes []Note `yaml:"notes"` +} + +var ( + loadOnce sync.Once + loaded []Note + loadErr error +) + +// All 返回内置清单里全部**通过校验**的条目。 +// +// 解析失败或个别条目非法时只跳过、不 panic:清单出问题绝不能拖垮启动。 +// 真正的把关在 TestNotesYAML(构建期),发布前就会炸出来。 +func All() []Note { + loadOnce.Do(func() { + var f noteFile + if err := yaml.Unmarshal(notesYAML, &f); err != nil { + loadErr = fmt.Errorf("解析内置升级须知清单失败: %w", err) + return + } + seen := make(map[string]bool, len(f.Notes)) + for _, n := range f.Notes { + if err := ValidateNote(n); err != nil { + continue + } + if seen[n.Id] { + continue + } + seen[n.Id] = true + loaded = append(loaded, n) + } + }) + return loaded +} + +// ParseAll 原样解析内置清单,不做任何过滤。 +// 只给构建期单测用:它要看到全部条目(含非法的)才能把问题炸出来。 +func ParseAll() ([]Note, error) { + var f noteFile + if err := yaml.Unmarshal(notesYAML, &f); err != nil { + return nil, err + } + return f.Notes, nil +} + +// LoadError 返回清单解析错误(仅供启动日志与单测使用) +func LoadError() error { + All() + return loadErr +} + +// ByID 按 id 取条目 +func ByID(id string) (Note, bool) { + for _, n := range All() { + if n.Id == id { + return n, true + } + } + return Note{}, false +} + +// ValidateNote 校验单条须知。构建期单测对全量清单逐条调用,任何一条不过即发布不了。 +func ValidateNote(n Note) error { + if !idPattern.MatchString(n.Id) { + return fmt.Errorf("id %q 非法:只允许小写字母、数字与下划线", n.Id) + } + if !semver.IsValid(n.Version) { + return fmt.Errorf("[%s] version %q 不是合法 semver(应形如 v1.3.24)", n.Id, n.Version) + } + switch n.Kind { + case KindNotice, KindAction, KindCheck: + default: + return fmt.Errorf("[%s] kind %q 非法", n.Id, n.Kind) + } + switch n.Level { + case LevelHigh, LevelNormal, LevelLow: + default: + return fmt.Errorf("[%s] level %q 非法", n.Id, n.Level) + } + switch n.Apply.Type { + case ApplyNone, ApplyNavigate: + if n.Apply.Item != "" || n.Apply.Value != "" { + return fmt.Errorf("[%s] apply.type=%s 不应带 item/value", n.Id, n.Apply.Type) + } + case ApplyConfigSet: + if n.Apply.Item == "" || n.Apply.Value == "" { + return fmt.Errorf("[%s] apply.type=config_set 必须同时给出 item 与 value", n.Id) + } + default: + return fmt.Errorf("[%s] apply.type %q 非法", n.Id, n.Apply.Type) + } + if n.Apply.Type == ApplyNavigate && n.Page == "" { + return fmt.Errorf("[%s] apply.type=navigate 必须给出 page", n.Id) + } + if n.Page != "" && !strings.HasPrefix(n.Page, "/") { + return fmt.Errorf("[%s] page %q 必须是以 / 开头的站内相对路径", n.Id, n.Page) + } + if n.Doc != "" && !strings.HasPrefix(n.Doc, docPrefix) { + return fmt.Errorf("[%s] doc %q 必须指向 %s", n.Id, n.Doc, docPrefix) + } + if err := validateText(n, "zh", n.ZH); err != nil { + return err + } + return validateText(n, "en", n.EN) +} + +// validateText 中英两套文案都必须齐全。 +// +// kind=action 强制要求 effect_on / effect_off / revert 三项: +// "点了之后和不点有什么区别、后悔了怎么撤" 正是这个功能存在的理由,缺一项就不该发出去。 +func validateText(n Note, lang string, t Text) error { + if strings.TrimSpace(t.Title) == "" { + return fmt.Errorf("[%s] %s.title 不能为空", n.Id, lang) + } + if strings.TrimSpace(t.Detail) == "" { + return fmt.Errorf("[%s] %s.detail 不能为空", n.Id, lang) + } + if n.Kind != KindAction { + return nil + } + if strings.TrimSpace(t.EffectOn) == "" { + return fmt.Errorf("[%s] kind=action 时 %s.effect_on 必填(做了之后有什么变化)", n.Id, lang) + } + if strings.TrimSpace(t.EffectOff) == "" { + return fmt.Errorf("[%s] kind=action 时 %s.effect_off 必填(不做会怎样,必须写代价)", n.Id, lang) + } + if strings.TrimSpace(t.Revert) == "" { + return fmt.Errorf("[%s] kind=action 时 %s.revert 必填(怎么撤销)", n.Id, lang) + } + return nil +} + +// DisplayVersion 把条目版本号归一化成给用户看的版本。 +// +// 清单里的 version 必须填「首次包含该改动的构建号」,beta 阶段加的就填 beta 号 +// (否则整个 beta 期都不会提示,见设计说明 §7)。但正式版用户在列表里看到 +// v1.3.24-beta.15 会疑惑"我用的不是 beta 啊",所以**存精确值、显示干净值**: +// 区间计算永远用原始值,只有展示这一层砍掉 -beta.x 后缀。 +func DisplayVersion(v string) string { + if !semver.IsValid(v) { + return v + } + canonical := semver.Canonical(v) + // Prerelease 返回带前导 '-' 的后缀(如 "-beta.15"),直接裁掉就是主版本号 + if pre := semver.Prerelease(canonical); pre != "" { + return strings.TrimSuffix(canonical, pre) + } + return canonical +} + +// RawVersionsFor 把用户在下拉里选的展示版本,还原成清单里所有对应的原始版本号。 +// +// 列表按 version 列过滤,而库里存的是原始值(可能带 -beta.x), +// 前端传过来的是归一化后的值,两边对不上,必须在这里展开。 +func RawVersionsFor(display string) []string { + display = strings.TrimSpace(display) + if display == "" { + return nil + } + var out []string + seen := make(map[string]bool) + for _, n := range All() { + if DisplayVersion(n.Version) != display || seen[n.Version] { + continue + } + seen[n.Version] = true + out = append(out, n.Version) + } + // 清单里找不到对应条目时,原样返回,让查询自然落空而不是退化成"不过滤" + if len(out) == 0 { + return []string{display} + } + return out +} + +// Select 挑出本次启动应当生成的条目。 +// +// last 为空 + 全新安装 → 只取 fresh_install 条目(不倒历史,否则新用户一上来就被淹) +// last 为空 + 存量库 → 只取"当前这一版"的条目(老用户首次升到带本功能的版本,起点未知) +// last < current → 取 (last, current] 区间内的非 fresh_install 条目 +// last >= current 或版本非法 → 不生成(降级路径只保留系统告警,见 service 层) +func Select(last, current string, freshInstall bool) []Note { + return selectFrom(All(), last, current, freshInstall) +} + +// selectFrom 是 Select 的纯函数内核,单测直接喂构造好的清单 +func selectFrom(all []Note, last, current string, freshInstall bool) []Note { + if !semver.IsValid(current) { + return nil + } + out := make([]Note, 0, len(all)) + + if strings.TrimSpace(last) == "" { + for _, n := range all { + if freshInstall { + if n.FreshInstall { + out = append(out, n) + } + continue + } + if !n.FreshInstall && semver.Compare(n.Version, current) == 0 { + out = append(out, n) + } + } + return out + } + + if !semver.IsValid(last) || semver.Compare(last, current) >= 0 { + return nil + } + for _, n := range all { + if n.FreshInstall { + continue + } + if semver.Compare(n.Version, last) > 0 && semver.Compare(n.Version, current) <= 0 { + out = append(out, n) + } + } + return out +} diff --git a/wafupgradenotice/notes_test.go b/wafupgradenotice/notes_test.go new file mode 100644 index 0000000..6248cb5 --- /dev/null +++ b/wafupgradenotice/notes_test.go @@ -0,0 +1,125 @@ +package wafupgradenotice + +import ( + "os" + "strings" + "testing" +) + +// TestNotesYAML 构建期校验内置清单:任何一条不合规都不许发出去。 +// +// 这是本功能唯一的把关点——清单在运行期是"跳过非法条目"的容错策略, +// 真出了问题界面上只会少一条,不会报错,所以必须在这里拦住。 +func TestNotesYAML(t *testing.T) { + notes, err := ParseAll() + if err != nil { + t.Fatalf("解析 upgrade_notes.yaml 失败: %v", err) + } + if len(notes) == 0 { + t.Fatal("内置清单为空") + } + + seen := make(map[string]bool, len(notes)) + for _, n := range notes { + if err := ValidateNote(n); err != nil { + t.Errorf("清单条目不合规: %v", err) + continue + } + if seen[n.Id] { + t.Errorf("条目 id 重复: %s(id 是幂等键,必须全局唯一)", n.Id) + } + seen[n.Id] = true + } +} + +// TestNotesTextNotIdentical 中英文案必须真的是两套,不能把中文原样复制到 en。 +func TestNotesTextNotIdentical(t *testing.T) { + notes, err := ParseAll() + if err != nil { + t.Fatalf("解析失败: %v", err) + } + for _, n := range notes { + if n.ZH.Title == n.EN.Title { + t.Errorf("[%s] 中英标题完全相同,en 文案疑似没翻译", n.Id) + } + if n.ZH.Detail == n.EN.Detail { + t.Errorf("[%s] 中英详情完全相同,en 文案疑似没翻译", n.Id) + } + } +} + +// TestConfigSetItemExists 一键应用(v2)引用的配置项必须真实存在。 +// +// 配置项在 waftask/task_config.go 里注册,这里直接扫源码做交叉比对: +// 清单里写错一个 item 名,v2 上线时就是"点了没反应",而且只有用户会发现。 +func TestConfigSetItemExists(t *testing.T) { + notes, err := ParseAll() + if err != nil { + t.Fatalf("解析失败: %v", err) + } + var wanted []string + for _, n := range notes { + if n.Apply.Type == ApplyConfigSet && n.Apply.Item != "" { + wanted = append(wanted, n.Apply.Item) + } + } + if len(wanted) == 0 { + t.Skip("清单里暂无 config_set 条目") + } + src, err := os.ReadFile("../waftask/task_config.go") + if err != nil { + t.Fatalf("读取 task_config.go 失败: %v", err) + } + text := string(src) + for _, item := range wanted { + if !strings.Contains(text, `"`+item+`"`) { + t.Errorf("配置项 %q 在 waftask/task_config.go 里没有注册", item) + } + } +} + +// TestValidateNoteRejectsBadInput 逐项验证校验规则本身没写漏。 +func TestValidateNoteRejectsBadInput(t *testing.T) { + good := Note{ + Id: "sample_note", + Version: "v1.3.24", + Kind: KindAction, + Level: LevelNormal, + Page: "/sys/SystemConfig", + Doc: "https://doc.samwaf.com/x.html", + Apply: Apply{Type: ApplyNavigate}, + ZH: Text{Title: "标题", Detail: "详情", EffectOn: "开了会怎样", EffectOff: "不开的代价", Revert: "怎么撤"}, + EN: Text{Title: "Title", Detail: "Detail", EffectOn: "on", EffectOff: "off", Revert: "revert"}, + } + if err := ValidateNote(good); err != nil { + t.Fatalf("合法条目被判为不合法: %v", err) + } + + cases := []struct { + name string + mutit func(n *Note) + }{ + {"id 含大写", func(n *Note) { n.Id = "BadId" }}, + {"version 非 semver", func(n *Note) { n.Version = "1.3.24" }}, + {"kind 非法", func(n *Note) { n.Kind = "whatever" }}, + {"level 非法", func(n *Note) { n.Level = "urgent" }}, + {"apply.type 非法", func(n *Note) { n.Apply = Apply{Type: "exec"} }}, + {"config_set 缺 item", func(n *Note) { n.Apply = Apply{Type: ApplyConfigSet, Value: "1"} }}, + {"navigate 带 item", func(n *Note) { n.Apply = Apply{Type: ApplyNavigate, Item: "x", Value: "1"} }}, + {"navigate 缺 page", func(n *Note) { n.Page = "" }}, + {"page 是外链", func(n *Note) { n.Page = "https://evil.example.com" }}, + {"doc 不是官方站", func(n *Note) { n.Doc = "https://evil.example.com/a" }}, + {"action 缺 effect_off", func(n *Note) { n.ZH.EffectOff = "" }}, + {"action 缺 revert(英文)", func(n *Note) { n.EN.Revert = "" }}, + {"英文标题为空", func(n *Note) { n.EN.Title = "" }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + n := good + c.mutit(&n) + if err := ValidateNote(n); err == nil { + t.Errorf("期望校验失败,实际通过了") + } + }) + } +} diff --git a/wafupgradenotice/select_test.go b/wafupgradenotice/select_test.go new file mode 100644 index 0000000..43c991a --- /dev/null +++ b/wafupgradenotice/select_test.go @@ -0,0 +1,138 @@ +package wafupgradenotice + +import ( + "sort" + "strings" + "testing" +) + +func fixture() []Note { + return []Note{ + {Id: "fresh_a", Version: "v1.3.20", FreshInstall: true}, + {Id: "fresh_b", Version: "v1.3.21", FreshInstall: true}, + {Id: "n_21", Version: "v1.3.21"}, + {Id: "n_22", Version: "v1.3.22"}, + {Id: "n_23", Version: "v1.3.23"}, + {Id: "n_24", Version: "v1.3.24"}, + } +} + +func ids(notes []Note) string { + out := make([]string, 0, len(notes)) + for _, n := range notes { + out = append(out, n.Id) + } + sort.Strings(out) + return strings.Join(out, ",") +} + +func TestSelectFrom(t *testing.T) { + cases := []struct { + name string + last string + current string + freshInstall bool + want string + }{ + {"升级跨两个版本", "v1.3.21", "v1.3.23", false, "n_22,n_23"}, + {"升级一个版本", "v1.3.22", "v1.3.23", false, "n_23"}, + {"同版本重启不生成", "v1.3.23", "v1.3.23", false, ""}, + {"降级不生成", "v1.3.24", "v1.3.22", false, ""}, + {"全新安装只给新手清单", "", "v1.3.23", true, "fresh_a,fresh_b"}, + {"老库无版本记录只给当前版本条目", "", "v1.3.23", false, "n_23"}, + {"老库无版本记录且当前版本无条目", "", "v1.3.99", false, ""}, + {"当前版本非法", "v1.3.21", "dev", false, ""}, + {"上次版本非法当作无区间", "garbage", "v1.3.23", false, ""}, + {"区间上界闭合", "v1.3.23", "v1.3.24", false, "n_24"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := ids(selectFrom(fixture(), c.last, c.current, c.freshInstall)) + if got != c.want { + t.Errorf("last=%q current=%q fresh=%v\n got: %q\nwant: %q", c.last, c.current, c.freshInstall, got, c.want) + } + }) + } +} + +// TestSelectFromExcludesFreshOnUpgrade 升级路径下绝不能混进"全新安装建议", +// 否则老用户升个级就会被要求"立即修改初始口令"。 +func TestSelectFromExcludesFreshOnUpgrade(t *testing.T) { + for _, n := range selectFrom(fixture(), "v1.3.19", "v1.3.24", false) { + if n.FreshInstall { + t.Errorf("升级路径混入了全新安装条目: %s", n.Id) + } + } +} + +// TestNoteTextLang 语言回落:非 en 一律给中文 +func TestNoteTextLang(t *testing.T) { + n := Note{ + ZH: Text{Title: "中文"}, + EN: Text{Title: "English"}, + } + if got := n.Text("en_US").Title; got != "English" { + t.Errorf("en_US 应取英文,实际 %q", got) + } + if got := n.Text("zh_CN").Title; got != "中文" { + t.Errorf("zh_CN 应取中文,实际 %q", got) + } + if got := n.Text("").Title; got != "中文" { + t.Errorf("空语言应回落中文,实际 %q", got) + } +} + +// TestAllSkipsInvalid 运行期加载必须只吐合法条目 +func TestAllSkipsInvalid(t *testing.T) { + if err := LoadError(); err != nil { + t.Fatalf("内置清单加载失败: %v", err) + } + for _, n := range All() { + if err := ValidateNote(n); err != nil { + t.Errorf("All() 返回了不合法条目: %v", err) + } + } +} + +// TestDisplayVersion 展示层砍掉 -beta.x 后缀:存精确值、显示干净值 +func TestDisplayVersion(t *testing.T) { + cases := map[string]string{ + "v1.3.24-beta.15": "v1.3.24", + "v1.3.24": "v1.3.24", + "v1.3.24-rc.1": "v1.3.24", + "v1.3": "v1.3.0", // Canonical 会补齐 patch 位 + "garbage": "garbage", + "": "", + } + for in, want := range cases { + if got := DisplayVersion(in); got != want { + t.Errorf("DisplayVersion(%q) = %q, want %q", in, got, want) + } + } +} + +// TestRawVersionsFor 下拉里选的展示版本要能还原回清单里的原始版本号, +// 否则库里存着 v1.3.24-beta.15、前端传 v1.3.24,过滤永远查不到东西。 +func TestRawVersionsFor(t *testing.T) { + if got := RawVersionsFor(""); got != nil { + t.Errorf("空版本应返回 nil,实际 %v", got) + } + // 清单里没有的版本原样返回,让查询自然落空而不是退化成"不过滤" + got := RawVersionsFor("v9.9.9") + if len(got) != 1 || got[0] != "v9.9.9" { + t.Errorf("未知版本应原样返回,实际 %v", got) + } + // 内置清单里的真实版本必须能被自己的展示值还原 + for _, n := range All() { + display := DisplayVersion(n.Version) + found := false + for _, raw := range RawVersionsFor(display) { + if raw == n.Version { + found = true + } + } + if !found { + t.Errorf("%s(%s) 的展示版本 %s 还原不回原始版本", n.Id, n.Version, display) + } + } +} diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml new file mode 100644 index 0000000..f52b14f --- /dev/null +++ b/wafupgradenotice/upgrade_notes.yaml @@ -0,0 +1,133 @@ +# 升级须知内置清单 +# +# 随版本编译进二进制,不走网络。发版时按 SamWafTechDoc/Plan/2026-08-20-版本升级须知-实施计划.md §10 +# 与 Update.md 的「重要说明」一起维护。 +# +# 硬性要求(构建期单测 wafupgradenotice/notes_test.go 强制): +# 1. id 全局唯一、只含小写字母/数字/下划线,一旦发布不得修改(它是幂等键) +# 2. version 必须是合法 semver(形如 v1.3.24) +# 3. kind=action 时,中英两套的 effect_on / effect_off / revert 都必填, +# 且 effect_off 必须写清代价(磁盘、性能、可能误拦、可能掉线),不许只写好处 +# 4. page 只能是站内相对路径,doc 只能指向 https://doc.samwaf.com/ +# 5. fresh_install: true 的条目只在"全新安装"时生成,不参与版本区间计算 + +notes: + # ---------------- 全新安装建议 ---------------- + - id: fresh_change_default_password + version: v1.3.21 + kind: action + level: high + fresh_install: true + page: /account/Account + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: navigate + zh: + title: 立即修改初始登录口令 + detail: 全新安装的初始口令是一次性凭据,写在安装目录 data/initial_password.txt 里,同时也打印在启动日志中。 + effect_on: 改完之后:初始口令立即失效,data/initial_password.txt 与启动日志里的那串字符再也登不进来。 + effect_off: 不改:任何拿到过安装日志、备份包或服务器文件的人,都能直接登录管理端,等同于把 WAF 的控制权公开。 + revert: 口令可以随时再改;忘记时可用后台命令行重置。 + en: + title: Change the initial login password now + detail: A fresh install generates a one-time password, written to data/initial_password.txt under the install directory and also printed to the startup log. + effect_on: After changing it the initial password stops working immediately, so the string in data/initial_password.txt and in the startup log can no longer be used to log in. + effect_off: If you skip this, anyone who has ever seen the install log, a backup archive or the server filesystem can log into the console - effectively handing over control of the WAF. + revert: You can change the password again at any time; if you forget it, reset it from the command line. + + - id: fresh_manage_trusted_proxies + version: v1.3.21 + kind: check + level: high + fresh_install: true + page: /sys/SystemConfig + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: navigate + zh: + title: 管理端前面有反向代理时,需要声明可信代理网段 + detail: 如果管理端(默认 26666 端口)前面还有 Nginx、云负载或 CDN,SamWaf 默认只认直连来源 IP,取到的会是代理机的地址。请在 conf/config.yml 的 security.manage_trusted_proxies 或「参数设置」里填写可信代理网段(CIDR,逗号分隔)。管理端直接对外、前面没有任何代理时,不需要动。 + effect_on: 配置后:管理端 IP 白名单、登录失败封禁、登录历史都基于真实客户端 IP 判定。 + effect_off: 不配置:以上判定全部落在代理机 IP 上——IP 白名单形同虚设,一次爆破就可能把所有人一起挡在门外。 + revert: 清空该配置即恢复为"只认直连 IP"。 + en: + title: Declare trusted proxy ranges if the console sits behind a reverse proxy + detail: If Nginx, a cloud load balancer or a CDN sits in front of the console (port 26666 by default), SamWaf only trusts the direct peer address, which is the proxy's IP. Set security.manage_trusted_proxies in conf/config.yml (CIDR list, comma separated) or on the Parameter Settings page. Nothing to do if the console is exposed directly. + effect_on: Once configured, the console IP allowlist, login failure banning and login history all work off the real client IP. + effect_off: Left unset, all of those decisions are made against the proxy's IP - the IP allowlist becomes meaningless and a single brute-force burst can lock everyone out at once. + revert: Clearing the setting restores the "direct peer only" behaviour. + + - id: fresh_enable_otp + version: v1.3.21 + kind: action + level: normal + fresh_install: true + page: /account/OTP + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: navigate + zh: + title: 建议为管理端开启两步验证(OTP) + detail: 给管理端登录加一层 TOTP 动态口令,支持任意标准验证器 App。 + effect_on: 开启后:登录除口令外还需输入 6 位动态码,口令泄露也无法单独登录;请务必留存好绑定二维码/密钥,换手机时需要它。 + effect_off: 保持关闭:口令一旦泄露(撞库、截图、备份外流),攻击者可以直接登录管理端。 + revert: 可在「双因素认证」页解绑;密钥丢失时可用后台命令行重置。 + en: + title: Turn on two-factor authentication for the console + detail: Adds a TOTP one-time code to console logins, compatible with any standard authenticator app. + effect_on: Once enabled, logging in requires a 6-digit code in addition to the password, so a leaked password alone is not enough. Keep the enrolment QR code or secret - you will need it when changing phones. + effect_off: Left off, a leaked password (credential stuffing, a screenshot, a stray backup) is enough for an attacker to log straight into the console. + revert: Unbind it on the two-factor page; if you lose the secret, reset it from the command line. + + - id: fresh_notify_channel + version: v1.3.21 + kind: action + level: normal + fresh_install: true + page: /notify/channel + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: navigate + zh: + title: 建议至少配置一个通知渠道 + detail: 钉钉、飞书、企业微信、Server 酱、邮件或自定义 Webhook 任选其一,用于接收攻击、证书到期、异常登录等告警。 + effect_on: 配置后:告警会主动推送到你填写的地址;这会产生指向该地址的出网请求(地址、请求头、报文模板都由你自己填)。 + effect_off: 不配置:所有告警只留在管理端里,需要你主动登录才看得到——证书到期、被打这类事往往就错过了。 + revert: 在「通知渠道」页停用或删除即可,停用后不再发起任何相关出网请求。 + en: + title: Configure at least one notification channel + detail: Pick DingTalk, Feishu, WeCom, ServerChan, e-mail or a custom webhook to receive attack, certificate expiry and abnormal login alerts. + effect_on: Once configured, alerts are pushed to the address you entered. This creates outbound requests to that address - the URL, headers and payload template are entirely yours to define. + effect_off: Without a channel, every alert stays inside the console and is only visible when you log in - which is exactly how expiring certificates and ongoing attacks get missed. + revert: Disable or delete the channel on the Notification Channel page; no related outbound request is made once it is off. + + # ---------------- 版本升级须知 ---------------- + - id: v1_3_21_password_bcrypt + version: v1.3.21 + kind: notice + level: high + page: '' + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: none + zh: + title: 登录口令存储方式由 MD5 切换为 bcrypt + detail: 向上升级无感知,登录方式与口令都不变。注意:如需回滚到 v1.3.21-beta.8 之前的版本,由于加密方式已切换,旧版本无法校验新格式的口令,需手工使用后台命令行重置密码。 + en: + title: Console passwords are now stored with bcrypt instead of MD5 + detail: Upgrading is transparent - the login flow and your password are unchanged. Note that if you roll back to a build older than v1.3.21-beta.8, that build cannot verify the new hash format and you will have to reset the password from the command line. + + - id: v1_3_22_postgresql + version: v1.3.22 + kind: notice + level: low + page: '' + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: none + zh: + title: 数据库新增支持 PostgreSQL + detail: 自 v1.3.22 起 conf/config.yml 的 database.driver 可选 postgres。现有 SQLite / MySQL 部署完全不受影响,无需任何操作。 + en: + title: PostgreSQL is now supported as a database backend + detail: From v1.3.22 you can set database.driver to postgres in conf/config.yml. Existing SQLite and MySQL deployments are unaffected and need no action.