ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
543 lines
17 KiB
Go
543 lines
17 KiB
Go
package server
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
"github.com/Autumn-27/artex/notify"
|
||
)
|
||
|
||
// 本文件是推送功能的 HTTP 接口。全部路由挂在 requireAuth 之后(见 Handler()),
|
||
// 与其它管理接口一致。
|
||
|
||
// notify_api.go 가 HTTP 응답으로 돌려주는 사용자 노출 오류 문구다. 한국어 UI 에서 알림
|
||
// 설정 요청이 실패하면 이 문구가 그대로 토스트로 뜨므로 한국어로 둔다. 식별자(id)·JSON
|
||
// 필드명·enum 값(realtime·digest)·채널 종류 키는 사용자가 설정을 고치는 데 쓰는 값이라
|
||
// 원문 그대로 둔다. %s·%q 가 든 상수는 fmt.Sprintf 형식 문자열이다. 用語: 渠道→채널,
|
||
// 通知推送→알림(크롬 nav "알림 발송"), 投递→전송, 推送模式→발송 모드. 로그·주석은 BRIEF
|
||
// 방침상 최하위라 이 묶음 밖이다.
|
||
const (
|
||
notifyErrBadJSON = "请求正文不是有效的 JSON 格式: "
|
||
notifyErrKindInvalidFmt = "渠道类型无效。可选值: %s"
|
||
notifyErrNameMissing = "请输入渠道名称"
|
||
notifyErrNameEmpty = "渠道名称不能为空"
|
||
notifyErrModeInvalid = "发送模式无效。可选值: realtime / digest"
|
||
notifyErrRateNegative = "发送频率限制不能为负数"
|
||
notifyErrChannelID = "渠道 id 无效"
|
||
notifyErrKindUnregFmt = "渠道类型 %q 未注册"
|
||
notifyErrDeliveryID = "投递 id 无效"
|
||
notifyErrChannelNotFound = "找不到通知渠道"
|
||
)
|
||
|
||
// 채널 연결을 점검할 때 보내는 테스트 메시지 본문이다. 사용자가 등록한 채널로 실제 발송되므로
|
||
// 한국어로 두되, 받는 사람이 실제 취약점으로 오해하지 않도록 한눈에 테스트임이 드러나게 한다.
|
||
const (
|
||
notifyTestName = "测试消息 · 渠道配置正常"
|
||
notifyTestClass = "连接测试"
|
||
notifyTestSummary = "这是 ARTEX 通知渠道测试消息。如果您收到此消息,说明渠道配置正常。"
|
||
)
|
||
|
||
// notifyChannelDTO 是渠道的对外表述。
|
||
//
|
||
// Config 是**掩码后**的配置:凭据字段被替换成 notify.MaskedPrefix 开头的值。
|
||
// 前端把掩码值原样提交回来即表示「这个字段没改」,服务端据此保留库中原值
|
||
// (见 notify.MergeConfig)。
|
||
type notifyChannelDTO struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Enabled bool `json:"enabled"`
|
||
Mode string `json:"mode"`
|
||
Config map[string]any `json:"config"`
|
||
Filter notify.Filter `json:"filter"`
|
||
RatePerMin int `json:"rate_per_min"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
// SecretKeys 告知前端哪些字段是凭据,据此渲染密码框与「留空即不改」的提示。
|
||
// 由渠道自己声明(notify.Channel.SecretKeys),前端不硬编码渠道知识。
|
||
SecretKeys []string `json:"secret_keys"`
|
||
}
|
||
|
||
// notifyDeliveryDTO 是投递历史的对外表述。
|
||
type notifyDeliveryDTO struct {
|
||
ID int64 `json:"id"`
|
||
FindingID int64 `json:"finding_id,string"`
|
||
EventKind string `json:"event_kind"`
|
||
ChannelID int64 `json:"channel_id"`
|
||
ChannelName string `json:"channel_name"`
|
||
ChannelKind string `json:"channel_kind"`
|
||
State string `json:"state"`
|
||
Attempts int `json:"attempts"`
|
||
LastError string `json:"last_error"`
|
||
BatchID *int64 `json:"batch_id,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||
NextAttempt time.Time `json:"next_attempt_at"`
|
||
// 消息标题摘要,让历史列表不必展开就能看出这条推的是什么。
|
||
Title string `json:"title"`
|
||
Severity string `json:"severity"`
|
||
}
|
||
|
||
func toNotifyChannelDTO(ch *db.NotificationChannel) notifyChannelDTO {
|
||
var cfg map[string]any
|
||
if len(ch.Config) > 0 {
|
||
_ = json.Unmarshal(ch.Config, &cfg)
|
||
}
|
||
if cfg == nil {
|
||
cfg = map[string]any{}
|
||
}
|
||
secrets := []string{}
|
||
if c, ok := notify.Get(ch.Kind); ok {
|
||
secrets = c.SecretKeys()
|
||
}
|
||
return notifyChannelDTO{
|
||
ID: ch.ID,
|
||
Name: ch.Name,
|
||
Kind: ch.Kind,
|
||
Enabled: ch.IsEnabled(),
|
||
Mode: ch.Mode,
|
||
Config: notify.MaskConfig(ch.Kind, cfg),
|
||
Filter: notify.ParseFilter(ch.Filter),
|
||
RatePerMin: ch.RatePerMin,
|
||
CreatedAt: ch.CreatedAt,
|
||
UpdatedAt: ch.UpdatedAt,
|
||
SecretKeys: secrets,
|
||
}
|
||
}
|
||
|
||
func toNotifyDeliveryDTO(dl *db.NotificationDelivery) notifyDeliveryDTO {
|
||
snap, _ := parseSnapshot(dl)
|
||
dto := notifyDeliveryDTO{
|
||
ID: dl.ID,
|
||
FindingID: dl.FindingID,
|
||
EventKind: dl.EventKind,
|
||
ChannelID: dl.ChannelID,
|
||
ChannelName: dl.ChannelName,
|
||
ChannelKind: dl.ChannelKind,
|
||
State: dl.State,
|
||
Attempts: dl.Attempts,
|
||
LastError: dl.LastError,
|
||
BatchID: dl.BatchID,
|
||
CreatedAt: dl.CreatedAt,
|
||
SentAt: dl.SentAt,
|
||
NextAttempt: dl.NextAttemptAt,
|
||
Severity: snap.Severity,
|
||
}
|
||
if snap.Name != "" {
|
||
dto.Title = snap.Name
|
||
} else {
|
||
dto.Title = snap.VulnClass
|
||
}
|
||
return dto
|
||
}
|
||
|
||
// notifyMeta 返回通知页需要的静态元数据与全局设置,一次请求拿全,
|
||
// 避免前端为了渲染一个下拉框发三次请求。
|
||
func (s *Server) notifyMeta(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
kinds := make([]map[string]any, 0, len(notify.Kinds()))
|
||
for _, k := range notify.Kinds() {
|
||
ch, _ := notify.Get(k)
|
||
kinds = append(kinds, map[string]any{
|
||
"kind": k,
|
||
"default_rate_per_min": ch.DefaultRatePerMin(),
|
||
"secret_keys": ch.SecretKeys(),
|
||
})
|
||
}
|
||
baseURL, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||
digest, _, _ := pg.GetSetting(settingNotifyDigestMinutes)
|
||
stats, err := pg.NotificationStatsSnapshot(r.Context())
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{
|
||
"kinds": kinds,
|
||
"enabled": pg.GetBool(settingNotifyEnabled, true),
|
||
"public_base_url": baseURL,
|
||
"digest_interval_min": digest,
|
||
"defaults": map[string]any{
|
||
"digest_interval_min": notifyDefaultDigestMinutes,
|
||
},
|
||
"stats": stats,
|
||
})
|
||
}
|
||
|
||
func (s *Server) notifyListChannels(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
channels, err := pg.ListNotificationChannels(r.Context())
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
out := make([]notifyChannelDTO, 0, len(channels))
|
||
for _, ch := range channels {
|
||
out = append(out, toNotifyChannelDTO(ch))
|
||
}
|
||
writeJSON(w, 200, map[string]any{"channels": out})
|
||
}
|
||
|
||
// notifyChannelRequest 是新建/更新渠道的请求体。
|
||
//
|
||
// 全部业务字段用指针,以便区分「没传」与「传了零值」:PATCH 语义下,
|
||
// 没传的字段必须保留库中原值。
|
||
type notifyChannelRequest struct {
|
||
Name *string `json:"name"`
|
||
Kind *string `json:"kind"`
|
||
Enabled *bool `json:"enabled"`
|
||
Mode *string `json:"mode"`
|
||
Config map[string]any `json:"config"`
|
||
Filter *notify.Filter `json:"filter"`
|
||
RatePerMin *int `json:"rate_per_min"`
|
||
}
|
||
|
||
func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
var req notifyChannelRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, notifyErrBadJSON+err.Error())
|
||
return
|
||
}
|
||
if req.Kind == nil || !notify.ValidKind(*req.Kind) {
|
||
writeErr(w, 400, fmt.Sprintf(notifyErrKindInvalidFmt, strings.Join(notify.Kinds(), " / ")))
|
||
return
|
||
}
|
||
name := ""
|
||
if req.Name != nil {
|
||
name = strings.TrimSpace(*req.Name)
|
||
}
|
||
if name == "" {
|
||
writeErr(w, 400, notifyErrNameMissing)
|
||
return
|
||
}
|
||
channel, _ := notify.Get(*req.Kind)
|
||
if err := channel.Validate(req.Config); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
ch := &db.NotificationChannel{
|
||
Name: name,
|
||
Kind: *req.Kind,
|
||
Enabled: req.Enabled,
|
||
Mode: db.NotifyModeRealtime,
|
||
RatePerMin: channel.DefaultRatePerMin(),
|
||
}
|
||
if req.Mode != nil {
|
||
if !db.ValidNotifyMode(*req.Mode) {
|
||
writeErr(w, 400, notifyErrModeInvalid)
|
||
return
|
||
}
|
||
ch.Mode = *req.Mode
|
||
}
|
||
if req.RatePerMin != nil {
|
||
// 显式给值就照用——包括 0,它表示「不限流」,是合法配置。
|
||
if *req.RatePerMin < 0 {
|
||
writeErr(w, 400, notifyErrRateNegative)
|
||
return
|
||
}
|
||
ch.RatePerMin = *req.RatePerMin
|
||
}
|
||
// 只有「字段缺省」才套用渠道默认值。默认值必须在这里决定而不是在 db 层:
|
||
// 只有请求体能区分「没传这个字段」与「显式传了 0」,而两者的含义完全不同
|
||
// (前者=用默认,后者=不限流)。db 层把 0 也当未指定,会让不限流配置不可达。
|
||
if req.RatePerMin == nil {
|
||
ch.RatePerMin = channel.DefaultRatePerMin()
|
||
}
|
||
if req.Filter != nil {
|
||
// 写入时校验取值受限的过滤字段(如 min_severity)。详见 notify.Filter.Validate:
|
||
// 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。
|
||
if err := req.Filter.Validate(); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
raw, _ := json.Marshal(req.Filter)
|
||
ch.Filter = raw
|
||
}
|
||
rawCfg, _ := json.Marshal(req.Config)
|
||
ch.Config = rawCfg
|
||
|
||
id, err := pg.SaveNotificationChannel(r.Context(), ch)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
id, ok := pathInt(r, "id")
|
||
if !ok {
|
||
writeErr(w, 400, notifyErrChannelID)
|
||
return
|
||
}
|
||
current, err := pg.NotificationChannelByID(r.Context(), id)
|
||
if err != nil {
|
||
notifyChannelLookupErr(w, err)
|
||
return
|
||
}
|
||
var req notifyChannelRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, notifyErrBadJSON+err.Error())
|
||
return
|
||
}
|
||
|
||
// kind 允许修改,但改类型意味着凭据字段整套替换,不能与旧配置合并。
|
||
kind := current.Kind
|
||
if req.Kind != nil {
|
||
if !notify.ValidKind(*req.Kind) {
|
||
writeErr(w, 400, fmt.Sprintf(notifyErrKindInvalidFmt, strings.Join(notify.Kinds(), " / ")))
|
||
return
|
||
}
|
||
kind = *req.Kind
|
||
}
|
||
channel, _ := notify.Get(kind)
|
||
|
||
var stored map[string]any
|
||
if kind == current.Kind {
|
||
if len(current.Config) > 0 {
|
||
_ = json.Unmarshal(current.Config, &stored)
|
||
}
|
||
}
|
||
if stored == nil {
|
||
stored = map[string]any{}
|
||
}
|
||
// 用 PrepareConfigUpdate 而不是裸的 MergeConfig:目标地址变更时必须让操作者
|
||
// 对凭据字段重新表态,否则「只改地址、凭据沿用」会把库里的真凭据发到新地址。
|
||
merged, err := notify.PrepareConfigUpdate(kind, stored, req.Config)
|
||
if err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
if err := channel.Validate(merged); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
rawCfg, _ := json.Marshal(merged)
|
||
|
||
ch := &db.NotificationChannel{
|
||
ID: id,
|
||
Name: current.Name,
|
||
Kind: kind,
|
||
Enabled: current.Enabled,
|
||
Mode: current.Mode,
|
||
Config: rawCfg,
|
||
Filter: current.Filter,
|
||
RatePerMin: current.RatePerMin,
|
||
}
|
||
if req.Name != nil {
|
||
if ch.Name = strings.TrimSpace(*req.Name); ch.Name == "" {
|
||
writeErr(w, 400, notifyErrNameEmpty)
|
||
return
|
||
}
|
||
}
|
||
if req.Enabled != nil {
|
||
ch.Enabled = req.Enabled
|
||
}
|
||
if req.Mode != nil {
|
||
if !db.ValidNotifyMode(*req.Mode) {
|
||
writeErr(w, 400, notifyErrModeInvalid)
|
||
return
|
||
}
|
||
ch.Mode = *req.Mode
|
||
}
|
||
if req.RatePerMin != nil {
|
||
if *req.RatePerMin < 0 {
|
||
writeErr(w, 400, notifyErrRateNegative)
|
||
return
|
||
}
|
||
ch.RatePerMin = *req.RatePerMin
|
||
}
|
||
if req.Filter != nil {
|
||
if err := req.Filter.Validate(); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
raw, _ := json.Marshal(req.Filter)
|
||
ch.Filter = raw
|
||
}
|
||
|
||
// 走 SetNotificationChannelEnabled 而非 SaveNotificationChannel 的路径,
|
||
// 是为了让「停用」同时把存量待发投递标记为 skipped,避免重新启用时收到
|
||
// 一批已过时的积压消息。
|
||
enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled
|
||
if enabledChanged {
|
||
// 先把配置更新落库(此时 enabled 用旧值,避免提前触发跳过逻辑),
|
||
// 再单独切开关。两步之间没有并发窗口:本接口是唯一改这两个字段的入口。
|
||
prev := ch.Enabled
|
||
ch.Enabled = current.Enabled
|
||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if err := pg.SetNotificationChannelEnabled(r.Context(), id, *prev); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"id": id})
|
||
return
|
||
}
|
||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) notifyDeleteChannel(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
id, ok := pathInt(r, "id")
|
||
if !ok {
|
||
writeErr(w, 400, notifyErrChannelID)
|
||
return
|
||
}
|
||
if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil {
|
||
notifyChannelLookupErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"ok": true})
|
||
}
|
||
|
||
// notifyTestChannel 用当前保存的配置发一条测试消息。
|
||
//
|
||
// 直接调用渠道 Send 而不经投递队列:测试的目的是立刻告诉用户「这套配置能不能
|
||
// 发出去」,走队列会把结果藏进投递历史,用户得再去翻一遍才知道成没成。
|
||
// 因此本接口是**同步**的,超时上限由 notify 包的 HTTP 客户端决定(15 秒)。
|
||
func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
id, ok := pathInt(r, "id")
|
||
if !ok {
|
||
writeErr(w, 400, notifyErrChannelID)
|
||
return
|
||
}
|
||
ch, err := pg.NotificationChannelByID(r.Context(), id)
|
||
if err != nil {
|
||
notifyChannelLookupErr(w, err)
|
||
return
|
||
}
|
||
channel, ok := notify.Get(ch.Kind)
|
||
if !ok {
|
||
writeErr(w, 400, fmt.Sprintf(notifyErrKindUnregFmt, ch.Kind))
|
||
return
|
||
}
|
||
var cfg map[string]any
|
||
if len(ch.Config) > 0 {
|
||
_ = json.Unmarshal(ch.Config, &cfg)
|
||
}
|
||
if err := channel.Validate(cfg); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
msg := notifyTestMessage(s.notifierBaseURL(pg))
|
||
start := time.Now()
|
||
// 测试消息只有一条,送达条数这里不需要(渠道长度上限对单条消息而言
|
||
// 由截断兜底,不涉及分段)。
|
||
if _, err := channel.Send(r.Context(), cfg, msg); err != nil {
|
||
// 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。
|
||
writeErr(w, 502, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{
|
||
"ok": true,
|
||
"latency_ms": time.Since(start).Milliseconds(),
|
||
})
|
||
}
|
||
|
||
// notifyTestMessage 构造测试消息。刻意用一眼能看出是测试的内容:
|
||
// 收到的人不应该把它误判成真实漏洞。
|
||
func notifyTestMessage(baseURL string) notify.Message {
|
||
return notify.Message{
|
||
Items: []notify.Item{{
|
||
FindingID: 0,
|
||
Name: notifyTestName,
|
||
VulnClass: notifyTestClass,
|
||
Severity: "low",
|
||
Summary: notifyTestSummary,
|
||
Assets: []string{"artex.example.com"},
|
||
DetailURL: baseURL,
|
||
}},
|
||
HomeURL: baseURL,
|
||
}
|
||
}
|
||
|
||
// notifierBaseURL 读回链用的外部地址。
|
||
func (s *Server) notifierBaseURL(pg *db.DB) string {
|
||
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||
return trimTrailingSlash(v)
|
||
}
|
||
|
||
func (s *Server) notifyListDeliveries(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
f := db.NotificationDeliveryFilter{
|
||
State: r.URL.Query().Get("state"),
|
||
EventKind: r.URL.Query().Get("event_kind"),
|
||
}
|
||
if v := r.URL.Query().Get("channel_id"); v != "" {
|
||
f.ChannelID = int64(atoiDefault(v, 0))
|
||
}
|
||
page := queryInt(r, "page", 1)
|
||
pageSize := queryInt(r, "page_size", 50)
|
||
items, total, err := pg.ListNotificationDeliveries(r.Context(), f, page, pageSize)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
out := make([]notifyDeliveryDTO, 0, len(items))
|
||
for _, dl := range items {
|
||
out = append(out, toNotifyDeliveryDTO(dl))
|
||
}
|
||
writeJSON(w, 200, map[string]any{"deliveries": out, "total": total, "page": page, "page_size": pageSize})
|
||
}
|
||
|
||
func (s *Server) notifyRetryDelivery(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
id, ok := pathInt(r, "id")
|
||
if !ok {
|
||
writeErr(w, 400, notifyErrDeliveryID)
|
||
return
|
||
}
|
||
if err := pg.RetryNotificationDelivery(r.Context(), id); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"ok": true})
|
||
}
|
||
|
||
// notifyChannelLookupErr 把「渠道不存在」翻译成 404,其余错误 500。
|
||
func notifyChannelLookupErr(w http.ResponseWriter, err error) {
|
||
if errors.Is(err, db.ErrNotificationChannelNotFound) {
|
||
writeErr(w, 404, notifyErrChannelNotFound)
|
||
return
|
||
}
|
||
writeErr(w, 500, err.Error())
|
||
}
|