处理 IP 地址时,很多旧代码会使用 net.IP。它能用,但类型是 []byte,可变、比较不方便,也容易在 IPv4 和 IPv6 之间绕晕。Go 1.18 引入了 net/netip,提供了更现代的 Addr 和 Prefix 类型。对新代码来说,优先学 netip 会更省心。
IP 地址在业务里很常见:后台白名单、登录风险判断、内网接口保护、限流维度、审计日志。它看起来只是字符串,实际有很多边界。
解析 IP
最基本的解析:
addr, err := netip.ParseAddr("192.168.1.20")
if err != nil {
return err
}
fmt.Println(addr.Is4())
Addr 是值类型,可以直接比较:
a, _ := netip.ParseAddr("127.0.0.1")
b, _ := netip.ParseAddr("127.0.0.1")
fmt.Println(a == b)
这比 net.IP 的字节切片比较直观很多。值类型也减少了被意外修改的可能。
判断私网地址
netip.Addr 有一些实用方法:
func describe(addr netip.Addr) string {
switch {
case addr.IsLoopback():
return "loopback"
case addr.IsPrivate():
return "private"
case addr.IsGlobalUnicast():
return "global"
default:
return "other"
}
}
IsPrivate 会识别常见私网地址,比如 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16,也包括 IPv6 的私有范围。业务上如果要阻止访问内网地址,用它比自己写字符串前缀可靠。
CIDR 匹配
白名单经常用 CIDR:
prefix, err := netip.ParsePrefix("192.168.1.0/24")
if err != nil {
return err
}
addr, _ := netip.ParseAddr("192.168.1.42")
fmt.Println(prefix.Contains(addr))
可以把多条白名单预先解析好:
type IPAllowList []netip.Prefix
func (l IPAllowList) Contains(addr netip.Addr) bool {
for _, p := range l {
if p.Contains(addr) {
return true
}
}
return false
}
配置加载时解析 CIDR,运行时只做匹配。不要每个请求都重新解析白名单字符串,那既浪费,也会让配置错误在请求路径里才暴露。
从 RemoteAddr 取 IP
HTTP 请求的 RemoteAddr 通常是 ip:port:
func remoteIP(r *http.Request) (netip.Addr, error) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return netip.Addr{}, err
}
return netip.ParseAddr(host)
}
IPv6 地址里本来就有冒号,所以不要自己用 strings.Split(r.RemoteAddr, ":")。net.SplitHostPort 会正确处理 IPv4、IPv6 和端口。
X-Forwarded-For 不能随便信
服务放在反向代理后面时,真实客户端 IP 可能在 X-Forwarded-For 或 X-Real-IP 里。但这些头也是客户端可以伪造的,除非请求来自你信任的代理。
func clientIP(r *http.Request, trustedProxy IPAllowList) (netip.Addr, error) {
remote, err := remoteIP(r)
if err != nil {
return netip.Addr{}, err
}
if !trustedProxy.Contains(remote) {
return remote, nil
}
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return remote, nil
}
first := strings.TrimSpace(strings.Split(xff, ",")[0])
return netip.ParseAddr(first)
}
这个例子只在远端地址属于可信代理时才读取 XFF。真实项目还要根据公司网关规范决定取第一个还是最后一个 IP。重点是:不要无条件相信请求头。
拒绝访问内网地址
如果你的服务允许用户输入 URL 并由服务端去请求,就要防 SSRF。至少要在解析目标主机后拒绝内网 IP、回环地址和未指定地址。
func safeTargetIP(addr netip.Addr) bool {
if addr.IsLoopback() || addr.IsPrivate() || addr.IsUnspecified() {
return false
}
return addr.IsGlobalUnicast()
}
这只是基础检查。完整 SSRF 防护还要考虑 DNS 重绑定、重定向、IPv6、代理和云厂商元数据地址。入门阶段先知道“用户输入的 URL 不能直接让服务器访问内网”,已经非常重要。
存储格式
日志和数据库里可以存 addr.String():
logger.Info("login failed", "ip", addr.String())
如果要做范围查询或高性能匹配,可以再考虑二进制存储。普通后台、审计日志和白名单配置,字符串足够直观。不要过早把 IP 存成整数,IPv6 会让这个设计变复杂。
测试不同类型地址
IP 相关函数最好覆盖 IPv4、IPv6、私网、回环和非法输入:
func TestSafeTargetIP(t *testing.T) {
cases := []struct {
ip string
want bool
}{
{"127.0.0.1", false},
{"192.168.1.1", false},
{"8.8.8.8", true},
{"::1", false},
}
for _, tc := range cases {
addr, err := netip.ParseAddr(tc.ip)
if err != nil {
t.Fatal(err)
}
if got := safeTargetIP(addr); got != tc.want {
t.Fatalf("%s got %v want %v", tc.ip, got, tc.want)
}
}
}
测试能提醒你不要只按 IPv4 思考。现在很多环境已经默认支持 IPv6,业务代码至少不要遇到 IPv6 就解析错误。
Prefix 的标准化
解析 CIDR 后,可以调用 Masked 得到规范化前缀。比如 192.168.1.42/24 实际代表的是整个 192.168.1.0/24 网段。
p, err := netip.ParsePrefix("192.168.1.42/24")
if err != nil {
return err
}
fmt.Println(p.Masked()) // 192.168.1.0/24
配置白名单时建议保存规范化结果。这样展示、比较和日志都更一致。用户输入主机地址加掩码并不罕见,程序应该把它整理成明确的网段。
端口和地址分开处理
如果配置里允许 host:port,不要先解析 IP。先拆端口,再解析 host:
func parseAddrPort(s string) (netip.AddrPort, error) {
ap, err := netip.ParseAddrPort(s)
if err != nil {
return netip.AddrPort{}, err
}
if !ap.Addr().IsValid() || ap.Port() == 0 {
return netip.AddrPort{}, fmt.Errorf("invalid address port")
}
return ap, nil
}
AddrPort 对 IPv6 尤其有用,因为 [::1]:8080 这种格式自己拆很容易错。网络相关代码尽量交给标准库解析,不要靠字符串位置猜。
小结
net/netip 提供了更适合新代码的 IP 类型:可比较、不可变、方法清晰。入门时可以用它处理 IP 解析、私网判断、CIDR 匹配和日志字段。
真正的难点不在 API,而在信任边界。RemoteAddr 是直接连接地址,代理头只有在可信代理后面才可信;用户输入 URL 时要防止访问内网;白名单配置要启动时解析并验证。把这些边界想清楚,IP 处理就不会停留在字符串拼接层面。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。