341 lines
9.5 KiB
Go
341 lines
9.5 KiB
Go
package common
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// 渠道调试日志传输层捕获:仅在渠道调试开关开启时,经 ctx 携带捕获器镜像
|
||
// 「系统→远程」「远程→系统」两段报文(最终形态,含协议转换与 header override 后的结果)。
|
||
// 开关关闭的请求 ctx 中无捕获器,DebugRoundTripper 直接透传,热路径零开销。
|
||
|
||
type debugAttemptCtxKey struct{}
|
||
|
||
// WithDebugAttempt 将本次尝试的捕获器挂入 ctx(作用域仅限当前 attempt,勿跨重试轮次复用)
|
||
func WithDebugAttempt(ctx context.Context, a *DebugAttemptCapture) context.Context {
|
||
return context.WithValue(ctx, debugAttemptCtxKey{}, a)
|
||
}
|
||
|
||
// DebugAttemptFromContext 取出当前 ctx 的捕获器;未开启调试时返回 nil
|
||
func DebugAttemptFromContext(ctx context.Context) *DebugAttemptCapture {
|
||
if ctx == nil {
|
||
return nil
|
||
}
|
||
a, _ := ctx.Value(debugAttemptCtxKey{}).(*DebugAttemptCapture)
|
||
return a
|
||
}
|
||
|
||
// DebugAttemptCapture 单次上游尝试的传输层捕获器。tee 完整累积请求/响应字节,不截断
|
||
// (调试用途,可能含 base64 图片;靠管理端提示及时清理控制数据量)。
|
||
type DebugAttemptCapture struct {
|
||
mu sync.Mutex
|
||
|
||
upstreamMethod string
|
||
upstreamURL string // query 中凭证参数已脱敏
|
||
upstreamReqHeaders map[string]string
|
||
upstreamRespHeaders map[string]string
|
||
|
||
upstreamReqBody []byte
|
||
upstreamRespBody []byte
|
||
|
||
upstreamStatus int
|
||
upstreamError string
|
||
upstreamLatencyMs int64
|
||
}
|
||
|
||
// captureUpstreamRequest 快照最终上游请求并替换 Body 为 tee(在 RoundTrip 内调用)
|
||
func (a *DebugAttemptCapture) captureUpstreamRequest(req *http.Request) {
|
||
a.mu.Lock()
|
||
a.upstreamMethod = req.Method
|
||
a.upstreamURL = MaskURLSecret(req.URL.String())
|
||
a.upstreamReqHeaders = MaskHeaders(req.Header)
|
||
a.mu.Unlock()
|
||
if req.Body != nil {
|
||
req.Body = newDebugTee(req.Body, a, &a.upstreamReqBody)
|
||
}
|
||
}
|
||
|
||
// captureUpstreamResponse 快照上游响应并替换 Body 为 tee
|
||
func (a *DebugAttemptCapture) captureUpstreamResponse(resp *http.Response) {
|
||
a.mu.Lock()
|
||
a.upstreamStatus = resp.StatusCode
|
||
a.upstreamRespHeaders = MaskHeaders(resp.Header)
|
||
a.mu.Unlock()
|
||
if resp.Body != nil {
|
||
resp.Body = newDebugTee(resp.Body, a, &a.upstreamRespBody)
|
||
}
|
||
}
|
||
|
||
func (a *DebugAttemptCapture) captureError(err error) {
|
||
a.mu.Lock()
|
||
a.upstreamError = err.Error()
|
||
a.mu.Unlock()
|
||
}
|
||
|
||
func (a *DebugAttemptCapture) setLatency(d time.Duration) {
|
||
a.mu.Lock()
|
||
a.upstreamLatencyMs = d.Milliseconds()
|
||
a.mu.Unlock()
|
||
}
|
||
|
||
// upstreamSnapshot 捕获数据快照(构建落库记录时调用一次;body 切片引用捕获缓冲,此后不再写入)
|
||
type upstreamSnapshot struct {
|
||
Method string
|
||
URL string
|
||
ReqHeaders map[string]string
|
||
RespHeaders map[string]string
|
||
ReqBody []byte
|
||
RespBody []byte
|
||
Status int
|
||
Err string
|
||
LatencyMs int64
|
||
}
|
||
|
||
func (a *DebugAttemptCapture) snapshot() upstreamSnapshot {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
return upstreamSnapshot{
|
||
Method: a.upstreamMethod,
|
||
URL: a.upstreamURL,
|
||
ReqHeaders: a.upstreamReqHeaders,
|
||
RespHeaders: a.upstreamRespHeaders,
|
||
ReqBody: a.upstreamReqBody,
|
||
RespBody: a.upstreamRespBody,
|
||
Status: a.upstreamStatus,
|
||
Err: a.upstreamError,
|
||
LatencyMs: a.upstreamLatencyMs,
|
||
}
|
||
}
|
||
|
||
// debugTeeReadCloser 透传读写并在读取时镜像字节到捕获缓冲
|
||
type debugTeeReadCloser struct {
|
||
src io.ReadCloser
|
||
a *DebugAttemptCapture
|
||
field *[]byte
|
||
}
|
||
|
||
func newDebugTee(src io.ReadCloser, a *DebugAttemptCapture, field *[]byte) *debugTeeReadCloser {
|
||
return &debugTeeReadCloser{src: src, a: a, field: field}
|
||
}
|
||
|
||
func (t *debugTeeReadCloser) Read(p []byte) (int, error) {
|
||
n, err := t.src.Read(p)
|
||
if n > 0 {
|
||
t.a.mu.Lock()
|
||
*t.field = append(*t.field, p[:n]...)
|
||
t.a.mu.Unlock()
|
||
}
|
||
return n, err
|
||
}
|
||
|
||
func (t *debugTeeReadCloser) Close() error {
|
||
return t.src.Close()
|
||
}
|
||
|
||
// DebugRoundTripper 渠道调试日志传输层包装。ctx 无捕获器时直接透传(一次 ctx 查找);
|
||
// 有捕获器时镜像最终上游请求/响应。连接池与 HTTP/2 仍由底层 *http.Transport 管理。
|
||
type DebugRoundTripper struct {
|
||
Base http.RoundTripper
|
||
}
|
||
|
||
// WrapDebugTransport 包装传输层供 http.Client 的 Transport 字段使用
|
||
func WrapDebugTransport(base *http.Transport) http.RoundTripper {
|
||
return &DebugRoundTripper{Base: base}
|
||
}
|
||
|
||
func (t *DebugRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||
a := DebugAttemptFromContext(req.Context())
|
||
if a == nil {
|
||
return t.Base.RoundTrip(req)
|
||
}
|
||
// Clone 后再替换 Body:不污染适配器持有的原始 req(适配器在 client.Do 返回后会
|
||
// Close httpReq.Body,tee 的 Close 透传保持原有语义)。
|
||
// 已知边角:底层 Transport 建连失败内部重放走 GetBody(重读原始 body),会绕过 tee,
|
||
// 该次捕获的段2 可能不完整——罕见且不影响请求本身,可接受。
|
||
clone := req.Clone(req.Context())
|
||
a.captureUpstreamRequest(clone)
|
||
start := time.Now()
|
||
resp, err := t.Base.RoundTrip(clone)
|
||
a.setLatency(time.Since(start))
|
||
if err != nil {
|
||
a.captureError(err)
|
||
return resp, err
|
||
}
|
||
a.captureUpstreamResponse(resp)
|
||
return resp, nil
|
||
}
|
||
|
||
// DebugClientWriter 渠道调试日志的客户端响应捕获 writer:完整累积、不截断
|
||
// (区别于审计的 ResponseCaptureWriter,后者有 256KB head+tail 截断)。
|
||
// 仅在调试开关开启时由 RelayHandler 包在 rc.Writer 外层,请求结束后由
|
||
// DebugSession.FinalizeAndSubmit 取走数据。
|
||
type DebugClientWriter struct {
|
||
http.ResponseWriter
|
||
mu sync.Mutex
|
||
buf []byte
|
||
status int
|
||
}
|
||
|
||
// NewDebugClientWriter 创建客户端响应捕获 writer
|
||
func NewDebugClientWriter(w http.ResponseWriter) *DebugClientWriter {
|
||
return &DebugClientWriter{ResponseWriter: w}
|
||
}
|
||
|
||
// WriteHeader 捕获状态码并委托底层 writer(仅首次生效)
|
||
func (w *DebugClientWriter) WriteHeader(code int) {
|
||
w.mu.Lock()
|
||
if w.status != 0 {
|
||
w.mu.Unlock()
|
||
return
|
||
}
|
||
w.status = code
|
||
w.mu.Unlock()
|
||
w.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
// Write 委托底层 writer 并完整镜像实际写出的字节
|
||
func (w *DebugClientWriter) Write(b []byte) (int, error) {
|
||
w.mu.Lock()
|
||
if w.status == 0 {
|
||
w.status = http.StatusOK
|
||
}
|
||
w.mu.Unlock()
|
||
|
||
n, err := w.ResponseWriter.Write(b)
|
||
|
||
w.mu.Lock()
|
||
w.buf = append(w.buf, b[:n]...)
|
||
w.mu.Unlock()
|
||
return n, err
|
||
}
|
||
|
||
// Flush 实现 http.Flusher,转发到底层 writer(流式响应依赖)
|
||
func (w *DebugClientWriter) Flush() {
|
||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||
f.Flush()
|
||
}
|
||
}
|
||
|
||
// Bytes 返回已捕获的完整响应体
|
||
func (w *DebugClientWriter) Bytes() []byte {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
return w.buf
|
||
}
|
||
|
||
// StatusCode 返回捕获的状态码(未显式设置时按 200)
|
||
func (w *DebugClientWriter) StatusCode() int {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
if w.status == 0 {
|
||
return http.StatusOK
|
||
}
|
||
return w.status
|
||
}
|
||
|
||
// HeaderSnapshot 从底层 writer 提取当前响应头快照(响应已写出后调用,取最终形态)
|
||
func (w *DebugClientWriter) HeaderSnapshot() map[string]string {
|
||
headers := make(map[string]string)
|
||
for k, vals := range w.ResponseWriter.Header() {
|
||
if len(vals) > 0 {
|
||
headers[k] = vals[0]
|
||
}
|
||
}
|
||
return headers
|
||
}
|
||
|
||
// ===== 脱敏与编码 =====
|
||
|
||
// sensitiveHeaderNames 凭证类请求/响应头,落库前脱敏(保留前6后4,足以区分 Key 又不泄露完整值)
|
||
var sensitiveHeaderNames = map[string]bool{
|
||
"authorization": true,
|
||
"proxy-authorization": true,
|
||
"cookie": true,
|
||
"set-cookie": true,
|
||
"x-api-key": true,
|
||
"api-key": true,
|
||
"x-goog-api-key": true,
|
||
"x-auth-token": true,
|
||
}
|
||
|
||
// urlSecretParams URL query 中携带凭证的参数名(如 Gemini 的 ?key=)
|
||
var urlSecretParams = map[string]bool{
|
||
"key": true,
|
||
"api_key": true,
|
||
"apikey": true,
|
||
"token": true,
|
||
"access_token": true,
|
||
}
|
||
|
||
// MaskSecret 脱敏单个凭证值:长度 < 12 全遮蔽;否则保留前 6 后 4 字符
|
||
func MaskSecret(v string) string {
|
||
if len(v) < 12 {
|
||
return "****"
|
||
}
|
||
return v[:6] + "****" + v[len(v)-4:]
|
||
}
|
||
|
||
// MaskHeaders 头快照并脱敏凭证类头;多值头用 ", " 连接
|
||
func MaskHeaders(h http.Header) map[string]string {
|
||
if h == nil {
|
||
return nil
|
||
}
|
||
out := make(map[string]string, len(h))
|
||
for k, vals := range h {
|
||
v := strings.Join(vals, ", ")
|
||
if sensitiveHeaderNames[strings.ToLower(k)] {
|
||
v = MaskSecret(v)
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MaskURLSecret 遮蔽 URL query 中的凭证参数值
|
||
func MaskURLSecret(rawURL string) string {
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil || u.RawQuery == "" {
|
||
return rawURL
|
||
}
|
||
q, err := url.ParseQuery(u.RawQuery)
|
||
if err != nil {
|
||
return rawURL
|
||
}
|
||
changed := false
|
||
for name, vals := range q {
|
||
if !urlSecretParams[strings.ToLower(name)] {
|
||
continue
|
||
}
|
||
for i := range vals {
|
||
vals[i] = MaskSecret(vals[i])
|
||
}
|
||
changed = true
|
||
}
|
||
if !changed {
|
||
return rawURL
|
||
}
|
||
// Encode 会把脱敏占位符 * 百分号转义成 %2A(可读性差),还原为字面 *
|
||
u.RawQuery = strings.ReplaceAll(q.Encode(), "%2A", "*")
|
||
return u.String()
|
||
}
|
||
|
||
// EncodeBody body 落库编码:有效 UTF-8 且不含 NUL 字节 → 原文 plain;
|
||
// 否则(multipart/图片/音频等二进制)→ base64(PostgreSQL TEXT 列不接受 NUL 与无效 UTF-8)
|
||
func EncodeBody(b []byte) (string, string) {
|
||
if len(b) == 0 {
|
||
return "", "plain"
|
||
}
|
||
if utf8.Valid(b) && !bytes.ContainsRune(b, 0) {
|
||
return string(b), "plain"
|
||
}
|
||
return base64.StdEncoding.EncodeToString(b), "base64"
|
||
}
|