zl程序教程

您现在的位置是:首页 >  大数据

当前栏目

使用 Golang 正确处理五大互联网注册机构的 IP 数据

数据Golang互联网IP 使用 五大 正确处理
2023-06-13 09:15:30 时间

补一篇内容,聊聊处理五大互联网注册机构提供的 IP 数据中的小坑。

写在前面

上个月,我写了一篇文章《正确处理全球五大互联网注册机构的 IP 数据》[1],来介绍如何处理全球五大互联网注册机构所提供的 IP 数据。

在实践的过程中,有一位读者在 GitHub 项目 Home-Network-Note[2]issue 里[3]反馈了一个问题,有一部分国家和地区的 IP 处理结果是错误的:

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 的指数来进行分配。这也是为什么,只有一部分国家和地区会出现这个问题。

举一个实际的例子(瑞士的一条分配记录):

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 的文档中[4],我们可以看到并没有哪一个掩码对应的数量是 768,最接近的掩码是 /23(512)和 /22(1k)。

IPv4 CIDR Chart

所以,当我们使用上一篇文章中提到的 32-log($5)/log(2) 的方式进行计算的话,将会得到下面的包含小数的错误结果:

91.216.83.0/22.415

解决这个问题并不难,如果一个 CIDR 不能够描述某个 IP 段,那么我们用两个(多个)就好。

比如,虽然没有能够直接表示 768 个 IP 的 CIDR,但是我们可以使用能够表示 256 个 IP 的 CIDR 和 能够表示 512 个 IP 的 CIDR 来完成我们的诉求。

在上一篇文章中,我们使用 bashawk 来完成数据的计算和处理,但是如果想完成上文中的需求,我们会涉及诸如:IP 地址到数值的转换计算、数值到 IP 地址的转换、关于掩码的计算,以及相关数值的校验、程序的异常处理等逻辑。

为了更简单的解决战斗,我们可以用 Golang 来完成处理程序。关于如搭建可维护的 Golang 开发环境,可以阅读之前的文章[5],这里就不再赘述啦。

设计 IP 数据处理程序

接下来,我们还是使用上文中“搞事情”的数据为例:

ripencc|CH|ipv4|91.216.83.0|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6

来看看如何设计一个简单、高效的程序,来正确处理这些 IP 数据。

为了方便我的读者偷懒,完整的程序,我已经上传到了 GitHub,有需要可以自取[6]

实现通用数据转换逻辑

为了相对高性能的完成数据处理,我们需要先定义两个函数,来解决 IP 字符串和 IP 数值之间的来回转换。

好在 Golang 中内置了不少方便的计算函数,实现它们只需要不到二十行代码:

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 地址段的“起止点”:

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
}

我们可以写一段简单的程序,来进行程序调用:

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))

运行这段代码,我们可以得到和下面一致的结果:

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 地址啦:

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地址”到数值的转化,在最终版本的程序中,我们可以将这一步进行简化。此处的核心计算逻辑如下:

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<<tail)-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<<tail)-1 <= int64(end) {
                break
            }
        }
        if tail < 0 {
            break
        }
        cidr = append(cidr, fmt.Sprintf("%s/%d", valueToIP(uint32(ip)).String(), 32-tail))
        ip += 1 << tail
        if ip-1 == int64(end) {
            break
        }
    }

    return cidr
}

执行上面的程序之后,将会遍历 IP 区间内的所有地址,以及尝试使用不同的掩码来判断是否能够包含该 IP。

结合上一小节,我们搞定调用程序:

cidrs, err := getCidrRangeList(ipStart, ipEnd)
if err != nil {
    fmt.Println(err)
    os.Exit(1)
}
fmt.Println(cidrs)

在程序执行完毕,我们将得到和下面一致的结果:

[91.216.83.0/24 91.216.84.0/23]

至此,我们已经完成了 IP 处理程序的核心逻辑。

编写入口函数

我们将上面的“调用程序”的片段组合在一起,可以得到一个简单的入口函数:

func main() {
    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))

    cidrs, err := getCidrRangeList(ipStart, ipEnd)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    fmt.Println(cidrs)
}

将上面的程序组合到一起,保存为 main.go,接着执行 go run main.go,不出意外,我们将得到类似下面的执行结果:

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
[91.216.83.0/24 91.216.84.0/23]

但是,这样的程序并不适合使用,在上一篇文章中[7],通过使用 Linux Pipeline 的方式来组合调用程序,可以对数据进行高效处理。所以,接下来,我们就针对上面的程序进行一些“简单”的调整吧:

func main() {
    fi, err := os.Stdin.Stat()
    if err != nil {
        panic(err)
    }
    if fi.Mode()&os.ModeNamedPipe == 0 {
        fmt.Println("Need to use linux shell pipe method to use")
        return
    }

    r := bufio.NewReader(os.Stdin)

    for {
        line, _, err := r.ReadLine()
        if err == io.EOF {
            break
        }
        ipStart, ipEnd, err := getRangeEndpointWithLine(string(line))
        if err == nil {
            cidrs, err := getCidrRangeList(ipStart, ipEnd)
            if err == nil {
                for _, cidr := range cidrs {
                    fmt.Println(cidr)
                }
            }
        }
    }
}

在上面的程序中,我们限定了这个程序只能和 Linux Shell Pipe 组合使用,在接收到 Linux Pipeline 源源不断的数据之后,会根据换行符拆分数据,然后喂给上文中我们实现的 CIDR 计算函数,并将计算结果进行输出。

根据上一篇文章,我们不难得到包含了各种区域的 IP 数据文件,假设我们将数据文件名称保存为 ip.txt,接下来只需要执行 cat ip.txt | go run main.go ,就能够看到正确的数据处理结果啦:

...
195.85.233.0/24
195.130.196.0/24
195.138.217.0/24
195.144.22.0/24
195.184.76.0/23
195.189.244.0/23
195.191.230.0/23
195.206.242.0/23
195.211.164.0/22
195.216.248.0/24
195.226.211.0/24
195.226.216.0/24
195.242.146.0/23
195.248.81.0/24
195.254.164.0/23
212.6.36.0/24
194.36.0.0/24

调整流式处理函数

虽然上面的程序已经实现了我们所需要的功能,不过,为了确保我们的核心计算逻辑都是正确的。最好还是补充一些测试代码,但如果保持上文中的 main 函数的写法,后续编写测试逻辑将会十分麻烦。

所以,我们还需要针对上面的程序进行一些简单的调整:

func processPipe(src *os.File, dest *os.File, testMode bool) {
    fi, err := src.Stat()
    if err != nil {
        panic(err)
    }

    if !testMode {
        if fi.Mode()&os.ModeNamedPipe == 0 {
            fmt.Println("Need to use linux shell pipe method to use")
            return
        }
    }

    r := bufio.NewReader(src)

    for {
        line, _, err := r.ReadLine()
        if err == io.EOF {
            break
        }
        ipStart, ipEnd, err := getRangeEndpointWithLine(string(line))
        if err == nil {
            cidrs, err := getCidrRangeList(ipStart, ipEnd)
            if err == nil {
                for _, cidr := range cidrs {
                    fmt.Fprint(dest, fmt.Sprintf("%s\n", cidr))
                }
            }
        }
    }
}

func main() {
    processPipe(os.Stdin, os.Stdout, false)
}

在完成程序对于流式处理逻辑和入口函数的拆分之后,我们就可以开始编写测试程序,来进一步验证程序的正确性啦。

验证程序

相比较程序设计,单元测试比较枯燥无味,这里直接给出实现代码:

package main

import (
    "bytes"
    "errors"
    "fmt"
    "io/ioutil"
    "math"
    "os"
    "strconv"
    "strings"
    "testing"
)

func TestIpToValue(t *testing.T) {
    _, err := ipToValue("1.1.1")
    if err == nil {
        t.Fatal("program does not catch errors")
    }
    _, err = ipToValue("2001:0db8:0000:0000:0000:ff00:0042:8329")
    if err == nil {
        t.Fatal("program does not catch errors")
    }
}

func TestGetRangeEndpointWithLine(t *testing.T) {
    const example1 = "ripencc|CH|ipv4|91.216.83.0|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6"
    start, end, err := getRangeEndpointWithLine(example1)
    if err != nil {
        t.Fatal("program execution error")
    }
    if start != "91.216.83.0" || end != "91.216.86.0" {
        t.Fatal("Calculation result is wrong")
    }

    _, _, err = getRangeEndpointWithLine("")
    if err == nil {
        t.Fatal("program does not catch errors")
    }

    const example2 = "ripencc|CH|ipv4|91.a.b.c|768|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6"
    _, _, err = getRangeEndpointWithLine(example2)
    if err == nil {
        t.Fatal("program does not catch errors")
    }

    const example3 = "ripencc|CH|ipv4|1.2.3.4|aaa|20100512|assigned|dda39a5e-9b71-4fce-b6fd-d857491ce5e6"
    _, _, err = getRangeEndpointWithLine(example3)
    if err == nil {
        t.Fatal("program does not catch errors")
    }
}

func TestGetCidrByRangeEndpoint(t *testing.T) {
    start, _ := ipToValue("91.216.83.0")
    end, _ := ipToValue("91.216.86.0")
    val := getCidrByRangeEndpoint(start, end)
    if val == nil {
        t.Fatal("program does not catch errors")
    }

    start, _ = ipToValue("91.216.87.0")
    end, _ = ipToValue("91.216.86.0")
    val = getCidrByRangeEndpoint(start, end)
    if val != nil {
        t.Fatal("program does not catch errors")
    }
}

func TestGetCidrRangeList(t *testing.T) {
    result, err := getCidrRangeList("91.216.83.0", "91.216.86.0")
    if err != nil {
        t.Fatal("program execution error")
    }
    if len(result) != 2 {
        t.Fatal("program execution error")
    }
    if result[0] != "91.216.83.0/24" || result[1] != "91.216.84.0/23" {
        t.Fatal("program execution error")
    }

    _, err = getCidrRangeList("0.1.2.a", "1.1.1.1")
    if err == nil {
        t.Fatal("program does not catch errors")
    }

    _, err = getCidrRangeList("1.1.1.1", "1.1.b")
    if err == nil {
        t.Fatal("program does not catch errors")
    }

}

func getRangeListByCidr(cidr string) (result []string, err error) {
    maskAndIP := strings.Split(cidr, "/")

    octets := make([]int, 4)
    for index, octet := range strings.Split(maskAndIP[0], ".") {
        octets[index], err = strconv.Atoi(octet)
        if err != nil {
            return nil, err
        }
        if octets[index] < 0 {
            return nil, errors.New("[ERROR] " + octet + " is an invalid octet.")
        }

    }
    if len(octets) < 4 {
        return nil, errors.New("[ERROR] CIDR range must include 4 octets.")
    }

    maskDigit, err := strconv.Atoi(maskAndIP[1])
    if err != nil {
        return nil, err
    }
    if maskDigit < 16 || maskDigit > 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 来进行基础功能验证,不出意外,我们将会得到类似下面的执行结果:

=== 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

当然,为了更加直观的了解程序的健壮程度,我们还可以使用下面的命令来查看代码覆盖率:

go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out && rm coverage.out

命令执行完毕,我们的浏览器会直接打开,并提示我们代码覆盖率已经达到了 96%+,基本算是一个比较健康的程序啦。

程序代码覆盖率

在完成了程序验证之后,我们就可以进行程序的编译和使用啦。

编译程序

在开始编译程序之前,我们还需要创建一个名为 go.mod 的文件,用来声明程序的名称,举个例子:

module github.com/soulteary/ip-cidr

go 1.18

保存好文件之后,我们在目录中执行下面的命令,进行程序的构建:

go build -ldflags "-w -s"

当命令执行完毕之后,我们就能够在当前文件夹中得到一个名为 ip-cidr 的可执行文件了。

使用程序处理 IP CIDR 数据

使用编译好的程序也非常简单,我们将需要处理的 IP 列表用 cat 读取,然后发送到程序中,然后使用输出重定向,将结果进行保存即可:

cat data.txt | ./ip-cidr > result.txt

最后

写到这里,正确处理 CIDR 数据就介绍完啦。如果你还有什么问题,欢迎在 GitHub 或者专栏中提出。

我们,下一篇文章再见。

--EOF

引用链接

[1] 《正确处理全球五大互联网注册机构的 IP 数据》: https://soulteary.com/2022/06/07/correct-handling-of-ip-data-from-the-world-s-top-five-internet-registries.html [2] Home-Network-Note: https://github.com/soulteary/Home-Network-Note/ [3] issue 里: https://github.com/soulteary/Home-Network-Note/issues/14 [4] RIPE NCC 的文档中: https://www.ripe.net/about-us/press-centre/understanding-ip-addressing [5] 之前的文章: https://soulteary.com/2022/07/04/build-a-maintainable-golang-development-environment.html [6] 可以自取: https://github.com/soulteary/Home-Network-Note/blob/master/scripts/ip/main.go [7] 上一篇文章中: https://soulteary.com/2022/06/07/correct-handling-of-ip-data-from-the-world-s-top-five-internet-registries.html 本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 署名 4.0 国际 (CC BY 4.0)