从头开始创建IP地址工具

最近我一直在研究互联网是如何组织的,并且使用whois数据。我正在创建一些简单的工具,这些工具可以处理IP地址,而无需任何来自CPAN的帮助。在工作上,我们通常使用Net::IP::XS来完成这些任务,但有时弄清楚事物底层的工作原理也很有趣。

转换为十进制

我相信您已经熟悉了IPv4地址的格式;这种“点分四元组”由四个介于0和255之间的数字组成,由点分隔。您的家庭WiFi网络可能从192.168.0.0开始。这种格式只是表示32位整数的一种方式;以下是这些数字及其等效的二进制表示

     192      168        0        0
11000000 10101000 00000000 00000000

要计算地址的十进制值,您需要一次性读取所有32位

11000000101010000000000000000000
                      3232235520

我发现将IPv4地址转换为十进制并将其存储在数据库中很有用;整数搜索比文本搜索要快得多。那么我们如何在Perl中做到这一点?这里有一种方法

#!/usr/bin/perl
my $ipv4 = '192.168.0.0';
my @bytes = split /\./, $ipv4;
my $decimal = unpack 'N', pack 'CCCC', @bytes; # 3232235520

此代码将IPv4字符串192.168.0.0拆分为包含4个数字的数组(192,168,0,0)。我使用pack将每个数字从Perl的表示形式转换为无符号8位整数(“C”代表char,即C语言类型)。然后我使用unpack一次性读取所有32位(“N”代表网络顺序中的无符号长整型 - 即大端)。

使用packunpack很方便,但这并不是将这些数字转换为单个32位整数的最快方式。我们可以通过乘法和指数运算来完成同样的任务

my $decimal = $bytes[0] * 2**24 + $bytes[1] * 2**16 + $bytes[2] * 2**8 + $bytes[3];

此操作将每个数字乘以2的适当次方(**是Perl的指数运算符):192必须乘以2^24,因为我们希望将其左移24位,168应该乘以2^16,依此类推。或者我可以使用位移来完成同样的事情

my $decimal = ($bytes[0] << 24) + ($bytes[1] << 16) + ($bytes[2] << 8) + $bytes[3];

使用指数或位移都比我的pack-unpack例程快3倍以上。这并不罕见:除了避免子例程调用外,编译器还对2的幂运算进行了优化。

您可能想知道这对于IPv6地址会怎样。从原则上讲,步骤是相同的,但更复杂:IPv6地址是128位整数,这超出了Perl原生处理的能力。IPv6地址还有更复杂的表示规则规则。我将在未来的文章中讨论IPv6。

将十进制转换回点分四元组

要从十进制数回到IPv4地址,只需逆转这个过程即可

#!/usr/bin/perl
my $decimal = 3232235776;
my @bytes = unpack 'CCCC', pack 'N', $decimal;
my $ipv4 = join '.', @bytes; # 192.168.1.0

这里我又使用了pack-unpack例程。我不确定是否有比这更快的指数/位移解决方案。我可以将十进制右移24位以得到192,然后左移192位并从十进制中减去它,然后右移十进制16位,依此类推。但这似乎很麻烦。

编辑:Dave Cross发布了一个解决方案,使用位图。

从CIDR表示法中提取范围

CIDR表示法是描述属于网络的连续IP地址范围的缩写方式。例如,您的家庭网络通常使用192.168.0.0/16进行管理。这可以读作“网络从192.168.0.0开始,网络掩码长度为16位”。换句话说,网络从192.168.0.0开始,到192.168.255.255结束。

CIDR功能强大,因为网络掩码不必是8的因子;阅读105.201.192.0/19并知道网络在哪里结束会更困难。这正是Perl可以帮助的地方。

#!/usr/bin/perl
my ($start_ipv4, $prefixlen) = split /\//, '105.201.192.0/19';
my @bytes = split /\./, $start_ipv4;
my $start_decimal = $bytes[0] * 2**24 + $bytes[1] * 2**16 + $bytes[2] * 2**8 + $bytes[3];
my $bits_remaining = 32 - $prefixlen;
my $end_decimal = $start_decimal + 2 ** $bits_remaining - 1;
my @bytes = unpack 'CCCC', pack 'N', $end_decimal;
my $end_ipv4 = join '.', @bytes; # 105.201.223.255

此代码首先将网络 105.201.192.0/19 分解为其起始 IPv4 地址和网络掩码前缀长度。然后,我使用之前相同的程序来获取十进制起始地址。为了找出最后一个网络地址,我再次使用指数运算:2 的剩余位数次幂减 1,就可以知道末地址比起始地址大多少。为了得到点分十进制数,我使用打包-解包操作将末地址的十进制数读回 4 个字节,并将它们再次连接起来。

关于脚本的一点说明

到目前为止,我所有的代码示例都使用了固定变量以使事情变得简单。但实际上,我不会编写这样的脚本。文本流是 Unix 系统的通用语言;因此,编写读取文本流并打印文本流的脚本更有用。然后你可以将数据通过管道输入和输出脚本,将程序连接起来以获取所需的转换。以下是我所说的例子

#!/usr/bin/perl
while (<<>>) {
  my @columns = split /\t/;
  my ($start_ipv4, $prefixlen) = split /\//, $columns[0];
  my @bytes = split /\./, $start_ipv4;
  my $start_decimal = $bytes[0] * 2**24 + $bytes[1] * 2**16 + $bytes[2] * 2**8 + $bytes[3];
  my $bits_remaining = 32 - $prefixlen;
  my $end_decimal = $start_decimal + 2 ** $bits_remaining - 1;

  while ($start_decimal <= $end_decimal) {
    my @bytes = unpack 'CCCC', pack 'N', $start_decimal;
    my $ipv4 = join '.', @bytes;
    print join "\t", $ipv4, @columns;
    $start_decimal++;
  }
}

此脚本枚举网络中的所有 IP 地址。我使用双菱形运算符从 STDIN 读取输入或将其参数视为文件名,自动打开和流式传输它们。我期望文本以制表符分隔的列,并且第一列包含要枚举的 CIDR。它执行转换并按制表符分隔的格式打印答案以及原始输入。

我可以通过管道输入来运行它

$ echo '129.232.156.16/29' | enum-ips
129.232.156.16  129.232.156.16/29
129.232.156.17  129.232.156.16/29
129.232.156.18  129.232.156.16/29
129.232.156.19  129.232.156.16/29
129.232.156.20  129.232.156.16/29
129.232.156.21  129.232.156.16/29
129.232.156.22  129.232.156.16/29
129.232.156.23  129.232.156.16/29

或传递要读取的文件名

$ enum-ips cidrs-1.txt cidrs-2.txt | head
102.32.0.0  102.32.0.0/15
102.32.0.1  102.32.0.0/15
102.32.0.2  102.32.0.0/15
102.32.0.3  102.32.0.0/15
102.32.0.4  102.32.0.0/15
102.32.0.5  102.32.0.0/15
102.32.0.6  102.32.0.0/15
102.32.0.7  102.32.0.0/15
102.32.0.8  102.32.0.0/15
102.32.0.9  102.32.0.0/15

使用 CIDR 表示法表示范围

CIDR 表示法既紧凑又方便;但 inetnum whois 对象 定义每个网段由其起始和结束 IPv4 地址组成,如下所示:“197.232.80.0 - 197.232.83.255”。因此,我编写了一个脚本将此字符串转换回 CIDR

#!/usr/bin/perl
while (<<>>) {
  my @columns = split /\t/;
  my ($start_ipv4, $end_ipv4) = split /\s+-\s+/, $columns[0];
  my $start_decimal = unpack 'N', pack 'CCCC', split /\./, $start_ipv4;
  my $end_decimal   = unpack 'N', pack 'CCCC', split /\./, $end_ipv4;
  my $prefixlen     = 32 - length sprintf "%0b", $end_decimal - $start_decimal;
  print join "\t", "$start_ipv4/$prefixlen", @columns;
}

该脚本逐行读取输入。它将字符串拆分为起始和结束 IPv4 地址,并使用相同的打包-解包程序将每个地址转换为十进制。然后,它通过找到起始和结束地址之间的差异来计算前缀长度,将差异转换为二进制字符串(使用 sprintf),然后从 32 位(因为 IPv4 地址是 32 位整数)中减去位数。

前缀长度计算的问题在于它使用了字符串化 - 如果有方法使用数字,那么坚持使用数字应该更快。让我们回顾一下我们知道的内容

  1. 我们可以使用 2 为基数来计算最大(无符号)32 位整数值:232 - 1
  2. IPv4 地址只是表示无符号 32 位整数的一种方式
  3. 对于 197.232.80.0 - 197.232.83.255 这样的输入,我们可以计算两个值之间的差异(1023)
  4. 我们知道基数是 2,结果是 1023;但我们不知道指数是多少:2x - 1 = 1023
  5. 为了解出 x,我们可以使用对数函数,它是指数运算的逆
  6. 解决方案是:x = log2 ⋅ (1023 + 1)

以下是 Perl 解决方案

my $prefixlen = 32 - int(log(1 + $end_decimal - $start_decimal) / log(2));

它使用 log 函数,它使用自然对数的基数 e(就像计算器上的 ln 按钮),所以它必须除以 log(2) 才能像 log2 一样使用。在基准测试中,我惊讶地发现使用 log 的解决方案仅比使用 sprintf 快几个百分点。

编辑:Dan Book 发布了一个 IP 地址到十进制的 解决方案,它使用 Socket

标签

David Farrell

David 是一名职业程序员,他经常 推文博客 关于代码和编程艺术。

浏览他们的文章

反馈

这篇文章有什么问题吗?请帮助我们通过在 GitHub 上打开一个 issue 或 pull request 来解决问题。