应使用 func(http.Handler) http.Handler 中间件实现 IP 黑名单,统一拦截所有请求;需解析 X-Forwarded-For 获取真实 IP 并校验可信代理;用 sync.RWMutex 保护 map[string]struct{} 实现热更新;返回 403 状态码且不输出敏感信息。

用 net/http 中间件做 IP 黑名单,别碰 http.Handler 接口本身
直接包装 http.Handler 是最干净的做法,不是写个函数塞进 http.HandleFunc 就完事。后者没法统一拦截、没法提前终止请求,容易漏掉静态文件或健康检查路径。
常见错误是把黑名单逻辑写在业务 handler 里,比如每个路由都加 if isBlockedIP(r.RemoteAddr) { return } —— 这样既重复又难维护,还可能因 panic 或 defer 延迟执行导致拦截失效。
- 用
func(http.Handler) http.Handler形式写中间件,确保所有请求必经此关 -
r.RemoteAddr默认带端口(如"192.168.1.100:54321"),得用net.ParseIP(strings.Split(r.RemoteAddr, ":")[0])提取纯 IP - 如果用了反向代理(Nginx / Cloudflare),
r.RemoteAddr是代理地址,必须读X-Forwarded-For并做可信校验,否则黑名单形同虚设
map[string]struct{} 存黑名单比切片快,但要注意热更新问题
查 IP 是否在黑名单里,用 map[string]struct{} 是标准做法,O(1) 查找,没额外内存开销。但别用 map[string]bool —— 多占一个 byte,没意义。
真正容易踩的坑是:黑名单配置改了,程序不重启就无效。硬编码或只在 init 里加载一次,线上根本没法动态封 IP。
立即学习“go语言免费学习笔记(深入)”;
- 用
sync.RWMutex包一层 map,写操作(如从 API 加新 IP)加写锁,读操作(每次请求检查)加读锁 - 避免在 HTTP handler 里直接调
os.ReadFile读黑名单文件 —— 每次请求都 IO,性能崩盘 - 如果用 Redis 存黑名单,别用
GET查单个 key,改用SISMEMBER blacklist_ips 192.168.1.100,减少网络往返
HTTP 状态码选 403 Forbidden,不是 404 或 429
封 IP 就是明确拒绝访问,不是资源不存在(404),也不是限流(429)。返回 403 能让客户端/爬虫/扫描器清楚知道“你被拒了”,也方便日志归类分析。
有些团队为了“隐藏服务存在”,故意返回 404,结果运维查日志时发现大量 404,误以为是路由配错了,反而耽误排查。
- 写响应前务必先调
w.WriteHeader(http.StatusForbidden),否则 Go 会自动发200 - 不要在响应体里输出详细原因,比如
"Your IP xxx is blocked"—— 暴露策略细节给攻击者 - 如果用了 Gin/Echo 等框架,别用
c.Abort()后再写状态码,要显式调c.Status(http.StatusForbidden)
测试时用 httptest.NewRequest 模拟真实请求头,别只测本地 IP
本地跑单元测试时,r.RemoteAddr 默认是 "127.0.0.1:12345",但这和线上完全两回事。真正要测的是带 X-Forwarded-For 的场景,以及多层代理下如何取真实 IP。
很多人写了中间件,一上生产就失效,就是因为测试只覆盖了最简 case,没模拟 Nginx 的 proxy_set_header X-Forwarded-For $remote_addr; 行为。
- 测试时手动设置
r.Header.Set("X-Forwarded-For", "192.168.1.100, 203.0.113.5"),验证是否取第一个可信 IP - 记得同时设置
r.Header.Set("X-Real-IP", "192.168.1.100"),有些旧系统依赖这个 header - 用
httptest.NewRecorder()捕获响应,断言rec.Code == http.StatusForbidden,而不是只看日志有没有打印
IP 黑名单看着简单,但真实环境里代理链、IPv6 地址格式、CIDR 段匹配、并发读写竞争——这些地方一不留神就绕进去半天。别指望一个正则或一个 map 解决所有问题。










