package channelbridge import ( "errors" "net/url" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本文件是「适配器拨号失败怎么写进日志」的**唯一**收敛点。 // // 为什么需要它:Connect 失败的错误串里几乎总带着**拨号地址** —— // qq-obv11 的 OneBot 端点按惯例把凭据写成 `?access_token=…`(也可以写成 // `user:pass@` 或别的键名),而 *url.Error 的 Error() 会把整条地址渲染进错误串: // `dial tcp wss://h/?access_token=…: …`。这条错误串原样进 WARN,就等于把平台凭据 // 写进日志 —— 而日志会被采集、tail、粘进工单,是最容易扩散的面。 // // 收敛口径与 trace 包完全一致(那里是**唯一一份**实现,本包只调用、不复制): // - trace.URLCause 剥掉 *url.Error 的外壳 —— 它的 Error() 就是整条地址; // 「包一层 %w 不是修复,只是换个位置泄漏」,剥壳才是; // - 残留文本里**内嵌**的地址(适配器自己用 %s 把地址拼进文案,例如 qq-obv11 的 // `dial onebot ws `)逐个换成 trace.MaskQuery 的形态:凭据类 // query/fragment 值与 userinfo 打码,host 与路径保留; // - trace.HostOf 单独给出目标来源 —— 打码之后「打到哪」这条诊断信息仍在; // - trace.RedactText 收尾:不在 URL 里的凭据形状(`Authorization: Bearer …` 这类 // 握手失败常见回显)也一并抹掉,并把整行压回一屏。 // // 🔴 本文件只影响**日志文案**:connectAndRun 的判定与分支一行未动 —— 谁连、连不上 // 之后留不留在连接表、要不要继续重试,全都不归本文件管(channelbridge 是历史敏感面)。 // connectFailureAttrs 把一次拨号失败整理成可以安全落日志的键值对:目标来源 // (host)+ 打过码的原因。它替代直接写 `"error", err` —— 后者会把整条拨号地址 // (含 access_token)原样带进 WARN。 func connectFailureAttrs(channel string, err error) []any { host := urlErrorHost(err) text, embeddedHost := maskEmbeddedURLs(trace.URLCause(err).Error()) if host == "" { host = embeddedHost } return []any{"channel", channel, "host", host, "error", trace.RedactText(text)} } // urlErrorHost 取 *url.Error 里那条地址的来源(scheme://host[:port],用户信息 // 已整段打码)。err 不是 *url.Error 时返回空串 —— 绝不回落到原串。 func urlErrorHost(err error) string { var uerr *url.Error if !errors.As(err, &uerr) { return "" } return trace.HostOf(uerr.URL) } // maskEmbeddedURLs 把自由文本里内嵌的地址逐个换成 trace.MaskQuery 的形态,并返回 // 第一个地址的来源(供日志的 host 字段)。解析不出 host 的地址整段替换成占位符: // 宁可丢掉那条地址,也不回落到原串(回落就是泄漏)。 func maskEmbeddedURLs(text string) (masked, host string) { var b strings.Builder rest := text for { i := strings.Index(rest, "://") if i < 0 { b.WriteString(rest) return b.String(), host } start := i for start > 0 && isSchemeByte(rest[start-1]) { start-- } end := i + len("://") for end < len(rest) && !isURLTailByte(rest[end]) { end++ } raw := rest[start:end] safe := trace.MaskQuery(raw) if safe == "" { safe = "[redacted-url]" } if host == "" { host = trace.HostOf(raw) } b.WriteString(rest[:start]) b.WriteString(safe) rest = rest[end:] } } // isSchemeByte 报告该字节能否出现在 scheme 里(RFC 3986:ALPHA / DIGIT / "+" / // "-" / ".")。回退扫描到 scheme 起点,才不会把 `ws://` 里的 `ws` 丢掉。 func isSchemeByte(c byte) bool { switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': return true case c == '+' || c == '-' || c == '.': return true default: return false } } // isURLTailByte 报告该字节是否**终止**一段地址(日志里内嵌地址的常见收尾:空白、 // 引号、括号、逗号/分号/花括号)。`]` 刻意不算终止符:IPv6 字面量 // (`http://[::1]:8080/x`)会被切成半截而解析不出 host。>= 0x7f 的字节也终止:那是 // 多字节字符的首字节,地址里不会出现裸中文,跟着它一起扫会把后面的整段话当成地址。 func isURLTailByte(c byte) bool { switch c { case ' ', '\t', '\n', '\r', '"', '\'', ')', '>', ',', ';', '}': return true default: return c >= 0x7f || c == 0 } }