feat(log): 多源 clientIP 降级与去重 ip_chain
按 EO→XFF非回环→X-Real→RemoteAddr 选取 ip;访问日志附带去重 ip_chain;登录限流改用 clientIP。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+87
-13
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+9
-4
@@ -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` |
|
||||
|
||||
@@ -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{
|
||||
// "注释": "配置即启用,非必须,默认无",
|
||||
|
||||
Reference in New Issue
Block a user