First Commit
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
package notify
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"time"
)
// dingTalkChannel 实现钉钉自定义机器人。
//
// 平台特性(决定了这里的实现取舍):
// - 单机器人限流 20 条/分钟,超发会被静默丢弃(HTTP 仍可能 200),
// 所以限流必须在客户端做,见 DefaultRatePerMin。
// - 安全设置三选一:加签 / 自定义关键词 / IP 白名单。加签是唯一不依赖
// 消息内容的方案,所以只支持加签(也支持三者都不开的裸 webhook)。
// - 成功/失败都返回 HTTP 200,靠 body 里的 errcode 区分——不检查 errcode
// 会把投递失败记成成功。
type dingTalkChannel struct{}
func (dingTalkChannel) Kind() string { return KindDingTalk }
func (dingTalkChannel) DefaultRatePerMin() int { return 20 }
// 钉钉的 Webhook 地址里带 access_token,本身就是凭据,因此整体掩码。
func (dingTalkChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
// 目标是钉钉的 Webhook 地址本身;改地址必须同时对新地址重新表态加签密钥。
func (dingTalkChannel) DestinationKeys() []string { return []string{"webhook"} }
func (dingTalkChannel) Validate(cfg map[string]any) error {
hook := cfgString(cfg, "webhook")
if hook == "" {
return errors.New("Webhook 주소가 없습니다")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
}
return nil
}
// Send 投递一次消息。有回链且是单条时用 ActionCard(带按钮),否则用 markdown。
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
hook := cfgString(cfg, "webhook")
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
endpoint, err := dingTalkSignedURL(hook, cfgString(cfg, "secret"), time.Now())
if err != nil {
return 0, Permanent(err)
}
title := markdownTitle(m)
// 钉钉 markdown 正文无明确字节上限,但仍做上限保护,避免证据字段异常膨胀。
text, kept := markdownBody(m, 20000)
var payload any
if !m.Batch && len(m.Items) == 1 && m.Items[0].DetailURL != "" {
payload = map[string]any{
"msgtype": "actionCard",
"actionCard": map[string]any{
"title": title,
"text": text,
"btnOrientation": "0",
"singleTitle": "상세 보기",
"singleURL": m.Items[0].DetailURL,
},
}
} else {
payload = map[string]any{
"msgtype": "markdown",
"markdown": map[string]any{"title": title, "text": text},
}
}
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
if err != nil {
return 0, err
}
// 钉钉把业务错误藏在 200 响应里。
var res struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("DingTalk 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
}
if res.ErrCode != 0 {
// 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误,
// 重试不会自愈。
return 0, Permanent(fmt.Errorf("DingTalk에서 오류가 발생했습니다 (%d): %s", res.ErrCode, res.ErrMsg))
}
return kept, nil
}
// dingTalkSignedURL 按官方加签规则给 webhook 追加 timestamp 与 sign 参数。
//
// 规则:待签串 = timestamp + "\n" + secret,HMAC-SHA256 的**密钥也是 secret**,
// 结果 base64 后 URL 编码。timestamp 是毫秒。secret 为空时原样返回,
// 以支持未开启加签的机器人。
func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
if secret == "" {
return hook, nil
}
ts := strconv.FormatInt(now.UnixMilli(), 10)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "\n" + secret))
sign := base64.StdEncoding.EncodeToString(mac.Sum(nil))
u, err := url.Parse(hook)
if err != nil {
// 不透传 err:url.Parse 的错误文本里带完整地址(含 access_token)。
return "", fmt.Errorf("Webhook 주소를 해석하지 못했습니다: %s", redactRequestTarget(hook))
}
q := u.Query()
q.Set("timestamp", ts)
q.Set("sign", sign)
u.RawQuery = q.Encode()
return u.String(), nil
}
// validateHTTPURL 校验地址可用、协议受支持,并对字面 IP 目标做内网判断。
//
// 两点讲究:
//
// 1. **错误信息必须脱敏**。url.Parse 自己返回的是 *url.Error,它的 Error() 带
// **完整原始地址**,而本功能这几家的地址里就嵌着凭据(钉钉 access_token、
// 企微 key、Telegram 的 bot token、飞书 hook id)。曾经这里直接 `return err`,
// 于是「地址格式非法」这条错误就把凭据带了出去,流向测试接口的 400 响应、
// 每次投递落库的 last_error、服务端日志与投递历史接口。
//
// 2. **字面 IP 直接判内网**,域名留给拨号阶段判(blockInternalDial 才是最终
// 生效点,也能覆盖 DNS 重绑定)。这里做一次是为了让保存配置时就能得到提示,
// 而不是等到第一次投递失败。
//
// 限制协议是防御性的:file:///gopher:// 之类会让 http.Client 产生意料之外的
// 行为(虽已被 scheme 检查挡下,但没有理由放开这个面)。
func validateHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("주소를 해석할 수 없습니다 (%s)", redactRequestTarget(raw))
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("http 또는 https 만 지원합니다. 받은 값: %q", u.Scheme)
}
if u.Host == "" {
return errors.New("호스트 이름이 없습니다")
}
if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedDialIP(ip) && !allowLocalTargets() {
return fmt.Errorf("로컬 또는 링크 로컬 주소 %s 로는 전송하지 않습니다 (로컬 서비스로 보내야 하면 %s=1 로 설정하세요)", ip, AllowLocalTargetsEnv)
}
return nil
}