From 25faa937244b046deeb42b3f3cdcb6fbdd9f05bc Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Thu, 23 Jul 2026 06:04:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(log):=20=E5=A4=9A=E6=BA=90=20clientIP=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E4=B8=8E=E5=8E=BB=E9=87=8D=20ip=5Fchain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 EO→XFF非回环→X-Real→RemoteAddr 选取 ip;访问日志附带去重 ip_chain;登录限流改用 clientIP。 Co-authored-by: Cursor --- application.go | 100 +++++++++++++++++++++++++++++++++++++------ clientip_test.go | 91 +++++++++++++++++++++++++++++++++++++++ code.go | 2 +- docs/Seq_日志集成.md | 13 ++++-- var.go | 2 +- 5 files changed, 189 insertions(+), 19 deletions(-) create mode 100644 clientip_test.go diff --git a/application.go b/application.go index d137a5f..2c76d98 100644 --- a/application.go +++ b/application.go @@ -108,20 +108,24 @@ func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) }) } -// clientIP 取客户端真实 IP:X-Real-IP → X-Forwarded-For 第一段 → RemoteAddr -func clientIP(req *http.Request) string { - if req == nil { +// isLoopbackIP 判断是否为回环地址(空串视为不可用) +func isLoopbackIP(ip string) bool { + ip = strings.TrimSpace(ip) + if ip == "" { + return true + } + parsed := net.ParseIP(ip) + if parsed == nil { + return false + } + return parsed.IsLoopback() +} + +// remoteHost 从 RemoteAddr 取出 host(去掉端口) +func remoteHost(req *http.Request) string { + if req == nil || req.RemoteAddr == "" { return "" } - if ip := strings.TrimSpace(req.Header.Get("X-Real-IP")); ip != "" { - return ip - } - if xff := req.Header.Get("X-Forwarded-For"); xff != "" { - if i := strings.Index(xff, ","); i >= 0 { - return strings.TrimSpace(xff[:i]) - } - return strings.TrimSpace(xff) - } host, _, err := net.SplitHostPort(req.RemoteAddr) if err == nil && host != "" { return host @@ -129,6 +133,72 @@ func clientIP(req *http.Request) string { return req.RemoteAddr } +// resolveClientIP 多源识别客户端 IP。 +// best:EO-Connecting-IP → XFF 第一个非回环 → 非回环 X-Real-IP → RemoteAddr +// (XFF 先于 X-Real:反代误把 CDN 节点写进 X-Real-IP 时仍能落到客户端) +// chain:各源头 IP 按首次出现顺序逗号拼接,同一 IP 只保留一次 +func resolveClientIP(req *http.Request) (best string, chain string) { + if req == nil { + return "", "" + } + eo := strings.TrimSpace(req.Header.Get("EO-Connecting-IP")) + xri := strings.TrimSpace(req.Header.Get("X-Real-IP")) + xffRaw := req.Header.Get("X-Forwarded-For") + ra := remoteHost(req) + + var xffParts []string + if xffRaw != "" { + for _, p := range strings.Split(xffRaw, ",") { + if p = strings.TrimSpace(p); p != "" { + xffParts = append(xffParts, p) + } + } + } + + seen := make(map[string]struct{}, 4) + var ordered []string + add := func(ip string) { + ip = strings.TrimSpace(ip) + if ip == "" { + return + } + if _, ok := seen[ip]; ok { + return + } + seen[ip] = struct{}{} + ordered = append(ordered, ip) + } + add(eo) + add(xri) + for _, p := range xffParts { + add(p) + } + add(ra) + chain = strings.Join(ordered, ",") + + if eo != "" && !isLoopbackIP(eo) { + return eo, chain + } + for _, p := range xffParts { + if !isLoopbackIP(p) { + return p, chain + } + } + if xri != "" && !isLoopbackIP(xri) { + return xri, chain + } + if ra != "" { + return ra, chain + } + return "", chain +} + +// clientIP 取客户端真实 IP(见 resolveClientIP) +func clientIP(req *http.Request) string { + ip, _ := resolveClientIP(req) + return ip +} + // shortID 生成 n 字节随机 hex(n=6 → 12 位) func shortID(n int) string { b := make([]byte, n) @@ -504,13 +574,17 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) { defer func() { //是否展示日志 if that.WebConnectLog != nil { + ip, ipChain := resolveClientIP(req) evt := that.WebConnectLog.Info(). - Str("ip", clientIP(req)). + Str("ip", ip). Str("method", context.Req.Method). Str("sid", sid). Str("request_id", requestId). Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00). Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00) + if ipChain != "" && ipChain != ip { + evt = evt.Str("ip_chain", ipChain) + } if country := req.Header.Get("EO-Client-IPCountry"); country != "" { evt = evt.Str("ip_country", country) } diff --git a/clientip_test.go b/clientip_test.go new file mode 100644 index 0000000..6e311f4 --- /dev/null +++ b/clientip_test.go @@ -0,0 +1,91 @@ +package hotime + +import ( + "net/http" + "testing" +) + +func TestResolveClientIP_EOPreferred(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Set("EO-Connecting-IP", "171.1.2.3") + req.Header.Set("X-Real-IP", "43.1.2.3") + req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3") + req.RemoteAddr = "127.0.0.1:12345" + + ip, chain := resolveClientIP(req) + if ip != "171.1.2.3" { + t.Fatalf("ip=%q want 171.1.2.3", ip) + } + // 去重:171 只出现一次 + if chain != "171.1.2.3,43.1.2.3,127.0.0.1" { + t.Fatalf("chain=%q", chain) + } +} + +func TestResolveClientIP_SkipLoopbackXReal(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Set("X-Real-IP", "127.0.0.1") + req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3") + req.RemoteAddr = "127.0.0.1:8080" + + ip, chain := resolveClientIP(req) + if ip != "171.1.2.3" { + t.Fatalf("ip=%q want 171.1.2.3", ip) + } + if chain != "127.0.0.1,171.1.2.3,43.1.2.3" { + t.Fatalf("chain=%q", chain) + } +} + +func TestResolveClientIP_XFFBeforeMisconfiguredXReal(t *testing.T) { + // 线上常见:X-Real-IP=CDN,XFF=客户端,CDN + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Set("X-Real-IP", "43.1.2.3") + req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3") + req.RemoteAddr = "127.0.0.1:8080" + + ip, chain := resolveClientIP(req) + if ip != "171.1.2.3" { + t.Fatalf("ip=%q want 171.1.2.3", ip) + } + if chain != "43.1.2.3,171.1.2.3,127.0.0.1" { + t.Fatalf("chain=%q", chain) + } +} + +func TestResolveClientIP_XRealWhenNoXFF(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Set("X-Real-IP", "203.0.113.9") + req.RemoteAddr = "10.0.0.1:80" + + ip, chain := resolveClientIP(req) + if ip != "203.0.113.9" { + t.Fatalf("ip=%q want 203.0.113.9", ip) + } + if chain != "203.0.113.9,10.0.0.1" { + t.Fatalf("chain=%q", chain) + } +} + +func TestResolveClientIP_DirectRemoteAddr(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.RemoteAddr = "192.168.1.8:5555" + + ip, chain := resolveClientIP(req) + if ip != "192.168.1.8" { + t.Fatalf("ip=%q want 192.168.1.8", ip) + } + if chain != "192.168.1.8" { + t.Fatalf("chain=%q", chain) + } + if clientIP(req) != ip { + t.Fatalf("clientIP mismatch") + } +} + +func TestResolveClientIP_NilReq(t *testing.T) { + ip, chain := resolveClientIP(nil) + if ip != "" || chain != "" { + t.Fatalf("want empty, got %q %q", ip, chain) + } +} diff --git a/code.go b/code.go index c057781..d2a8cee 100644 --- a/code.go +++ b/code.go @@ -963,7 +963,7 @@ var TptProject = Proj{ }, "login": func(that *Context) { // 限速:同一 IP 1分钟内最多尝试10次 - ip := that.Req.Header.Get("X-Forwarded-For") + ip := clientIP(that.Req) if ip == "" { ip = that.Req.RemoteAddr } diff --git a/docs/Seq_日志集成.md b/docs/Seq_日志集成.md index 1195889..9c7c19d 100644 --- a/docs/Seq_日志集成.md +++ b/docs/Seq_日志集成.md @@ -67,11 +67,14 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64()) ## 客户端 IP 与地域 -零配置,优先级: +零配置。`ip` 选取优先级(跳过空值与回环 `127.0.0.1` / `::1`): -1. `X-Real-IP`(EdgeOne / 反代透传的真实 IP) -2. `X-Forwarded-For` **第一段** -3. `RemoteAddr` +1. `EO-Connecting-IP`(EdgeOne 真实建连 IP) +2. `X-Forwarded-For` **从左到右第一个非回环**(反代误把 CDN 写入 `X-Real-IP` 时仍能落到客户端) +3. `X-Real-IP`(非回环) +4. `RemoteAddr` + +访问日志另附 `ip_chain`:上述源头出现过的 IP 按**首次出现**顺序逗号拼接,**同一 IP 不重复**;若与 `ip` 相同则省略该字段。 请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。 @@ -112,6 +115,8 @@ multiWriter(hotimev1.5/log/logger.go) | `caller` | `caller` | 调用位置 | | `sid` | `sid` | sessionId 前 12 位 | | `request_id` | `request_id` | 单次请求 id | +| `ip` | `ip` | 客户端最佳 IP | +| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) | | `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 | | 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 | | — | `instance` | `ip:port` | diff --git a/var.go b/var.go index d73207f..f088c71 100644 --- a/var.go +++ b/var.go @@ -61,7 +61,7 @@ var ConfigNote = Map{ "logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取", "webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能", "webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置", - "seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port;请求日志自动带 sid(sessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志优先取 X-Real-IP,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/Error,Seq 与文件仍按 logLevel 全量", + "seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port;请求日志自动带 sid(sessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志 ip 按 EO-Connecting-IP→XFF非回环→X-Real-IP→RemoteAddr 选取,多源去重记 ip_chain,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/Error,Seq 与文件仍按 logLevel 全量", "seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写", //"codeConfig": Map{ // "注释": "配置即启用,非必须,默认无",