本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2022年07月16日 统计字数: 4180字 阅读时间: 9分钟阅读 本文链接: https://soulteary.com/2022/07/16/correct-handling-of-ip-data-from-the-world-s-top-five-internet-registries-using-golang.html ----- # 使用 Golang 正确处理五大互联网注册机构的 IP 数据 补一篇内容,聊聊处理五大互联网注册机构提供的 IP 数据中的小坑。 ## 写在前面 上个月,我写了一篇文章[《正确处理全球五大互联网注册机构的 IP 数据》](https://soulteary.com/2022/06/07/correct-handling-of-ip-data-from-the-world-s-top-five-internet-registries.html),来介绍如何处理全球五大互联网注册机构所提供的 IP 数据。 在实践的过程中,有一位读者在 GitHub 项目 [Home-Network-Note](https://github.com/soulteary/Home-Network-Note/) 的 [issue 里](https://github.com/soulteary/Home-Network-Note/issues/14)反馈了一个问题,有一部分国家和地区的 IP 处理结果是错误的: ```bash 109.94.112.0/20.1926 109.95.136.0/21.6781 129.181.0.0/13.6781 130.248.58.0/21.415 130.248.68.0/18.6781 134.98.184.0/17.8301 ``` 由于历史原因,互联网注册机构 IP 分配和管理是先松后紧的,最初看似海量的 IP,分配到最后变成了稀缺资源,为了避免 IP 分配浪费,在分配后期的时候,有一些 IP 段中的可用地址数量没有严格的按照 2 的指数来进行分配。这也是为什么,只有一部分国家和地区会出现这个问题。 举一个实际的例子(瑞士的一条分配记录): ```bash ripencc|CH|ipv4|91.216.83.0|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6 ``` 这个地址分配描述中,是注册管理机构,为瑞士( “海尔维地亚联邦”Confoederatio Helvetica )分配了从 `91.216.83.0` 开始的 768 个 IPv4 地址。然而在 [RIPE NCC 的文档中](https://www.ripe.net/about-us/press-centre/understanding-ip-addressing),我们可以看到并没有哪一个掩码对应的数量是 768,最接近的掩码是 `/23`(512)和 `/22`(1k)。 ![IPv4 CIDR Chart](https://attachment.soulteary.com/2022/07/16/cidr-chart.jpg) 所以,当我们使用上一篇文章中提到的 `32-log($5)/log(2)` 的方式进行计算的话,将会得到下面的包含小数的错误结果: ```bash 91.216.83.0/22.415 ``` 解决这个问题并不难,**如果一个 CIDR 不能够描述某个 IP 段,那么我们用两个(多个)就好。** 比如,虽然没有能够直接表示 768 个 IP 的 CIDR,但是我们可以使用能够表示 256 个 IP 的 CIDR 和 能够表示 512 个 IP 的 CIDR 来完成我们的诉求。 在上一篇文章中,我们使用 `bash` 和 `awk` 来完成数据的计算和处理,但是如果想完成上文中的需求,我们会涉及诸如:IP 地址到数值的转换计算、数值到 IP 地址的转换、关于掩码的计算,以及相关数值的校验、程序的异常处理等逻辑。 为了更简单的解决战斗,我们可以用 `Golang` 来完成处理程序。关于如搭建可维护的 Golang 开发环境,可以阅读[之前的文章](https://soulteary.com/2022/07/04/build-a-maintainable-golang-development-environment.html),这里就不再赘述啦。 ## 设计 IP 数据处理程序 接下来,我们还是使用上文中“搞事情”的数据为例: ```bash ripencc|CH|ipv4|91.216.83.0|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6 ``` 来看看如何设计一个简单、高效的程序,来正确处理这些 IP 数据。 为了方便我的读者偷懒,完整的程序,我已经上传到了 GitHub,有需要[可以自取](https://github.com/soulteary/Home-Network-Note/blob/master/scripts/ip/main.go)。 ### 实现通用数据转换逻辑 为了相对高性能的完成数据处理,我们需要先定义两个函数,来解决 IP 字符串和 IP 数值之间的来回转换。 好在 Golang 中内置了不少方便的计算函数,实现它们只需要不到二十行代码: ```go func ipToValue(ipAddr string) (uint32, error) { ip := net.ParseIP(ipAddr) if ip == nil { return 0, errors.New("Malformed IP address") } ip = ip.To4() if ip == nil { return 0, errors.New("Malformed IP address") } return binary.BigEndian.Uint32(ip), nil } func valueToIP(val uint32) net.IP { bytes := make([]byte, 4) binary.BigEndian.PutUint32(bytes, val) ip := net.IP(bytes) return ip } ``` ### 获取 IP 地址段的起止点 想要获取 IP 地址段的“起止点”,我们需要先将原始数据中的第4个(IP起始地址)和第5个(IP个数)字段取出,然后将起始 IP 地址转换为数值,将两个字段数据进行相加,得到这个 IP 地址段的“起止点”: ```go func getRangeEndpointWithLine(line string) (ipStart string, ipEnd string, err error) { data := strings.Split(line, "|") if len(data) < 4 { return "", "", errors.New("Malformed data format.") } count, err := strconv.Atoi(data[4]) if err != nil { return "", "", errors.New("The text contains an invalid number of IPs.") } ipStart = data[3] ipStartValue, err := ipToValue(ipStart) if err != nil { return "", "", err } ipEndValue := ipStartValue + uint32(count) ipEnd = valueToIP(ipEndValue).String() return ipStart, ipEnd, nil } ``` 我们可以写一段简单的程序,来进行程序调用: ```go const src = "ripencc|CH|ipv4|91.216.83.0|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6" fmt.Println(src) ipStart, ipEnd, err := getRangeEndpointWithLine(src) if err != nil { fmt.Println(err) os.Exit(1) } fmt.Println(fmt.Sprintf("[IP Start-End] %s - %s", ipStart, ipEnd)) ``` 运行这段代码,我们可以得到和下面一致的结果: ```bash ripencc|CH|ipv4|91.216.83.0|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6 [IP Start-End] 91.216.83.0 - 91.216.86.0 ``` ### 枚举 IP 地址段所有地址 当我们得到了 IP 地址段的开始地址和结束地址之后,可以根据这个范围,来计算所有地址的 CIDR 地址啦: ```go func getCidrRangeList(ipStart string, ipEnd string) ([]string, error) { ipStartValue, err := ipToValue(ipStart) if err != nil { return nil, err } ipEndValue, err := ipToValue(ipEnd) if err != nil { return nil, err } if ipEndValue != 0 { ipEndValue-- } cidr := getCidrByRangeEndpoint(ipStartValue, ipEndValue) return cidr, nil } ``` 因为在上一小节中,我们使用 IP 地址的方式,来展示过程中的结果,所以这里多了一次“IP地址”到数值的转化,在最终版本的程序中,我们可以将这一步进行简化。此处的核心计算逻辑如下: ```go func getCidrByRangeEndpoint(start, end uint32) []string { if start > end { return nil } // use uint64 to prevent overflow ip := int64(start) tail := int64(0) cidr := make([]string, 0) // decrease mask bit for { // count number of tailing zero bits for ; tail < 32; tail++ { if (ip>>(tail+1))<<(tail+1) != ip { break } } if ip+(1< int64(end) { break } cidr = append(cidr, fmt.Sprintf("%s/%d", valueToIP(uint32(ip)).String(), 32-tail)) ip += 1 << tail } // increase mask bit for { for ; tail >= 0; tail-- { if ip+(1< 32 { return nil, errors.New("[ERROR] Invalid mask => " + maskAndIP[1] + ".") } numberOfIps := int(math.Pow(2, float64(32-maskDigit))) for index := 0; index < numberOfIps; index++ { if octets[0] > 255 { return nil, errors.New("[ERROR] Invalid address/mask specified: leftmost octet would be greater than 255.") } result = append(result, fmt.Sprintf("%d.%d.%d.%d", octets[0], octets[1], octets[2], octets[3])) octets[3]++ if octets[3] > 255 { octets[2]++ octets[3] = 0 } if octets[2] > 255 { octets[1]++ octets[2] = 0 } if octets[1] > 255 { octets[0]++ octets[1] = 0 } } return result, nil } func TestExpendCIDR(t *testing.T) { cidrs, _ := getCidrRangeList("91.216.83.0", "91.216.86.0") var ipList []string for _, cidr := range cidrs { ipSubList, err := getRangeListByCidr(cidr) if err == nil { ipList = append(ipList, ipSubList...) } } if len(ipList) != 768 { t.Fatal("Tried to expand CIDR , got IP number mismatch") } } func TestProcessPipe(t *testing.T) { content := []byte("ripencc|CH|ipv4|91.216.83.0|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6") mockInput, err := ioutil.TempFile("", "test-process-pipe") if err != nil { t.Fatal(err) } defer os.Remove(mockInput.Name()) if _, err := mockInput.Write(content); err != nil { t.Fatal(err) } if _, err := mockInput.Seek(0, 0); err != nil { t.Fatal(err) } mockSuccessOutput, err := ioutil.TempFile("", "test-pipe-output-success") if err != nil { t.Fatal(err) } defer os.Remove(mockSuccessOutput.Name()) mockFailOutput, err := ioutil.TempFile("", "test-pipe-output-fail") if err != nil { t.Fatal(err) } defer os.Remove(mockFailOutput.Name()) processPipe(mockInput, mockSuccessOutput, true) processPipe(mockInput, mockFailOutput, false) success, err := os.ReadFile(mockSuccessOutput.Name()) if err != nil { t.Fatal(err) } fail, err := os.ReadFile(mockFailOutput.Name()) if err != nil { t.Fatal(err) } if !(bytes.Contains(success, []byte("91.216.83.0/2491.216.84.0/23")) && bytes.Contains(success, []byte("91.216.83.0/2491.216.84.0/23"))) { t.Fatal("content output is incorrect") } if !bytes.Contains(fail, []byte("Need to use linux shell pipe method to use")) { t.Fatal("content output is incorrect2") } if err := mockSuccessOutput.Close(); err != nil { t.Fatal(err) } if err := mockFailOutput.Close(); err != nil { t.Fatal(err) } if err := mockInput.Close(); err != nil { t.Fatal(err) } } ``` 上面的程序实现了绝大多数函数的功能和分支覆盖,将上面的内容保存为 `main_test.go`,并和上文中的程序放置在相同的目录中,接着,执行 `go test -v` 来进行基础功能验证,不出意外,我们将会得到类似下面的执行结果: ```bash === RUN TestIpToValue --- PASS: TestIpToValue (0.00s) === RUN TestGetRangeEndpointWithLine --- PASS: TestGetRangeEndpointWithLine (0.00s) === RUN TestGetCidrByRangeEndpoint --- PASS: TestGetCidrByRangeEndpoint (0.00s) === RUN TestGetCidrRangeList --- PASS: TestGetCidrRangeList (0.00s) === RUN TestExpendCIDR --- PASS: TestExpendCIDR (0.00s) === RUN TestProcessPipe --- PASS: TestProcessPipe (0.00s) PASS ok github.com/soulteary/ip-cidr 0.608s ``` 当然,为了更加直观的了解程序的健壮程度,我们还可以使用下面的命令来查看代码覆盖率: ```bash go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out && rm coverage.out ``` 命令执行完毕,我们的浏览器会直接打开,并提示我们代码覆盖率已经达到了 96%+,基本算是一个比较健康的程序啦。 ![程序代码覆盖率](https://attachment.soulteary.com/2022/07/16/coverage.jpg) 在完成了程序验证之后,我们就可以进行程序的编译和使用啦。 ## 编译程序 在开始编译程序之前,我们还需要创建一个名为 `go.mod` 的文件,用来声明程序的名称,举个例子: ```bash module github.com/soulteary/ip-cidr go 1.18 ``` 保存好文件之后,我们在目录中执行下面的命令,进行程序的构建: ```bash go build -ldflags "-w -s" ``` 当命令执行完毕之后,我们就能够在当前文件夹中得到一个名为 `ip-cidr` 的可执行文件了。 ## 使用程序处理 IP CIDR 数据 使用编译好的程序也非常简单,我们将需要处理的 IP 列表用 `cat` 读取,然后发送到程序中,然后使用输出重定向,将结果进行保存即可: ```bash cat data.txt | ./ip-cidr > result.txt ``` ## 最后 写到这里,正确处理 CIDR 数据就介绍完啦。如果你还有什么问题,欢迎在 GitHub 或者专栏中提出。 我们,下一篇文章再见。 --EOF