Files
artex/notify/dingtalk.go
T
dela 0335d572de
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
First Commit
2026-10-09 08:38:16 +08:00

161 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}