IT技术博客大学习 共学习 共进步

扫描全国的公网IP需要多久?

鸟窝 2026-06-03 09:03:23 累计浏览 9 次
本机暂存

自从加入百度负责物理网络的监控业务之后,我大部分的都是编写各种各样额度底层的网络程序。业余时间我也是编写一些有趣的网络程序,不仅仅是兴趣,也是为未来的某个业务探索一下技术方案。

而且这次,我想知道,就在我这一个10年前的小mini机器4核机器上,在家庭网络中扫描全国(中国大陆)的所有的公网IP地址需要多少时间。

利用它,我可以知道和全国各省市的运营商、云服务商的联通情况。有没有运营商的出口故障以及IP已没有被运营商或者有关部门劫持。

TL;DR: 一共扫描了3亿个地址(343142912),当前ping的通的IP 592万个(5923768),耗时1小时(1h2m57.973755197s)。

这次我重构了以前的一个扫描公网IP的程序。先前的程序使用gopacket收发包,也使用gopacket组装包。但是gopacket很讨厌的的一个地方是它依赖libpcap库,没有办法在禁用CGO的情况下。

事实上利用Go的扩展包icmp和ipv4,我们完全可以不使用gopacket实现这个功能,本文介绍具体的实现。

程序的全部代码在:https://github.com/smallnest/fishfinder

程序的主要架构

程序使用ICMP协议进行探测。

首先它启动一个goroutine解析全国的IP地址。IP地址文件每一行都是一个网段,它对每一个网段解析成一组IP地址,把这组IP地址扔进input channel。

一个发送goroutine从input通道中接收IP地址,然后组装成ICMP echo包发送给每一个IP地址,它只负责发送,发送完所有的地址就返回。

一个接收goroutine处理接收到的ICMP reply 回包,并将结果写入到output channel中。

主程序不断的从output中接收已经有回包的IP并打印到日志中,直到所有的IP都处理完就退出。

这里涉及到并发编程的问题,几个goroutine怎么协调:

  • IP解析和任务分发goroutine和发送goroutine通过input通讯。分发goroutine处理完所有的IP后,就会关闭input通知发送goroutine。
  • 发送goroutine得知input关闭,就知道已经处理完所有的IP,发送完最后的IP后把output关闭。
  • 接收goroutine往output发送接收到回包的IP, 如果output关闭,再往output发送就会panic,程序中捕获了panic。不过还没到这一步主程序应该就退出了。
  • 主程序从output读取IP, 一旦output关闭,主程序就打印统计信息后推出。

如果你对Go并发编程有疑问,可以阅读极客时间上的《Go并发编程实战课》专栏,或者图书《深入理解Go并发编程》。
如果你是Rust程序员,不就我会推出《Go并发编程实战课》姊妹专栏,专门介绍Rust并发编程。
如果你对网络编程感兴趣,今年我还想推出《深入理解网络编程》的专栏或者图书,如果你感兴趣,欢迎和我探讨。

主程序的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main
import (
"flag"
"time"
"github.com/kataras/golog"
)
var (
protocol = flag.String("p", "icmp", "The protocol to use (icmp, tcp or udp)")
)
// 嵌入ip.sh
func main() {
flag.Parse()
input := make(chan []string, 1024)
output := make(chan string, 1024)
scanner := NewICMPScanner(input, output)
var total int
var alive int
golog.Infof("start scanning")
start := time.Now()
// 将待探测的IP发送给send goroutine
go func() {
lines := readIPList()
for _, line := range lines {
ips := cidr2IPList(line)
input <- ips
total += len(ips)
}
close(input)
}()
// 启动 send goroutine
scanner.Scan()
// 接收 send goroutine 发送的结果, 直到发送之后5秒结束
for ip := range output {
golog.Infof("%s is alive", ip)
alive++
}
golog.Infof("total: %d, alive: %d, time: %v", total, alive, time.Since(start))
}

接下来介绍三个三个主要goroutine的逻辑。

公网IP获取以及任务分发

首先你需要到互联网管理中心下载中国大陆所有的注册的IP网段,这是从亚太互联网络信息中心下载的公网IP信息,实际上可以探测全球的IP,这里以中国大陆的公网IP为例。

通过下面的代码转换成网段信息:

1
2
3
#!/bin/bash
wget -c -O- http://ftp.apnic.net/stats/apnic/delegated-apnic-latest | awk -F '|' '/CN/&&/ipv4/ {print $4 "/" 32-log($5)/log(2)}' | cat > ipv4.txt

ipv4.txt文件中是一行行的网段:

1
2
3
4
5
6
7
8
1.0.1.0/24
1.0.2.0/23
1.0.8.0/21
1.0.32.0/19
1.1.0.0/24
1.1.2.0/23
1.1.4.0/22
...

数据量不大,我们全读取进来(如果太多的话我们就流式读取了)。
解析每一行的网段,转换成IP地址列表,然后发送给input通道。
等处理完就把inpout通道关闭。

1
2
3
4
5
6
7
8
9
go func() {
lines := readIPList()
for _, line := range lines {
ips := cidr2IPList(line)
input <- ips
total += len(ips)
}
close(input)
}()

发送逻辑

我使用了ICMPScanner结构体来管理发送和接收的逻辑。看名字你也可以猜测到我们将来还可以使用TCP/UDP等协议进行探测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type ICMPScanner struct {
src net.IP
input chan []string
output chan string
}
// 调大缓存区
// sysctl net.core.rmem_max
// sysctl net.core.wmem_max
func NewICMPScanner(input chan []string, output chan string) *ICMPScanner {
localIP := getLocalIP()
s := &ICMPScanner{
input: input,
output: output,
src: net.ParseIP(localIP),
}
return s
}
func (s *ICMPScanner) Scan() {
go s.recv()
go s.send(s.input)
}

send方法负责发送ICMP包,recv方法负责接收ICMP包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// send sends a single ICMP echo request packet for each ip in the input channel.
func (s *ICMPScanner) send(input chan []string) error {
defer func() {
time.Sleep(5 * time.Second)
close(s.output)
golog.Infof("send goroutine exit")
}()
id := os.Getpid() & 0xffff
// 创建 ICMP 连接
conn, err := icmp.ListenPacket("ip4:icmp", s.src.String())
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 不负责接收数据
filter := createEmptyFilter()
if assembled, err := bpf.Assemble(filter); err == nil {
conn.IPv4PacketConn().SetBPF(assembled)
}
... // 先忽略,后面再介绍
return nil
}

send方法中,我们首先创建一个ICMP连接,我通过icmp包创建了一个连接,然后设置了一个BPF过滤器,过滤掉我们不关心的包。
这是一个技巧,这个连接我们不关心接收到的包,只关心发送的包,所以我们设置了一个空的过滤器。

这个设计本来是为了将来的性能扩展做准备,可以创建多个连接用来更快的发送。不过目前我们只使用一个连接,所以这个连接其实可以和接收goroutine共享,目前的设计还是发送和接收使用各自的连接。

接下来就是发送的逻辑了,也就是上面省略的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
seq := uint16(0)
for ips := range input {
for _, ip := range ips {
dst, err := net.ResolveIPAddr("ip", ip)
if err != nil {
golog.Fatalf("failed to resolve IP address: %v", err)
}
// 构造 ICMP 报文
msg := &icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{
ID: id,
Seq: int(seq),
Data: []byte("Hello, are you there!"),
},
}
msgBytes, err := msg.Marshal(nil)
if err != nil {
golog.Errorf("failed to marshal ICMP message: %v", err)
}
// 发送 ICMP 报文
_, err = conn.WriteTo(msgBytes, dst)
if err != nil {
golog.Errorf("failed to send ICMP message: %v", err)
}
seq++
}
}

发送循环从input通道中读取IP地址,然后构造ICMP echo报文,发送到目标地址。

  • 从 input channel 读取 IP 列表
  • 对每个 IP 执行以下操作:
    1. 解析 IP 地址
    2. 构造 ICMP echo 请求报文
    3. 序列化报文
    4. 发送到目标地址

icmp报文中的ID我们设置为进程的PID,在接收的时候可以用来判断是否是我们发送的回包。

接收逻辑

接收逻辑比较简单,我们只需要接收ICMP回包,然后解析出IP地址,然后发送到output通道。

首先我们创建一个ICMP连接,然后循环接收ICMP回包,解析出IP地址,然后发送到output通道。

我们只需处理ICMPTypeEchoReply类型的回包,然后判断ID是否是我们发送的ID,如果是就把对端的IP发送到output通道。

我们通过ID判断回包针对我们的场景就足够了,不用再判断seq甚至payload信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func (s *ICMPScanner) recv() error {
defer recover()
id := os.Getpid() & 0xffff
// 创建 ICMP 连接
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 接收 ICMP 报文
reply := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(reply)
if err != nil {
log.Fatal(err)
}
// 解析 ICMP 报文
msg, err := icmp.ParseMessage(protocolICMP, reply[:n])
if err != nil {
golog.Errorf("failed to parse ICMP message: %v", err)
continue
}
// 打印结果
switch msg.Type {
case ipv4.ICMPTypeEchoReply:
echoReply, ok := msg.Body.(*icmp.Echo)
if !ok {
continue
}
if echoReply.ID == id {
s.output <- peer.String()
}
}
}
}

可以看到,200行代码基本就可以我们扫描全国公网IP的程序了。你也可以尝试扫描一下全球的IP地址,看看需要多少时间。

对了,下面是我运行这个程序的输出:

1
2
3
4
5
...
[INFO] 2025/01/26 22:01 223.255.236.221 is alive
[INFO] 2025/01/26 22:01 223.255.252.9 is alive
[INFO] 2025/01/26 22:01 send goroutine exit
[INFO] 2025/01/26 22:01 total: 343142912, alive: 5923768, time: 1h2m57.973755197s

建议继续学习

  1. libcurl的使用总结(二) (累计阅读 15,002)
  2. Go Reflect 性能 (累计阅读 13,866)
  3. 面向“接口”编程和面向“实现”编程 (累计阅读 13,847)
  4. 一种基于长连接的社交游戏服务器程序构架 (累计阅读 7,425)
  5. 浅析linux kernel network之socket创建 (累计阅读 6,684)
  6. TCP链接主动关闭不发fin包奇怪行为分析 (累计阅读 6,644)
  7. TCP之close_wait (累计阅读 6,442)
  8. iPhone下的libcurl with SSL for iOS (累计阅读 6,265)
  9. libevent源码浅析: http库 (累计阅读 6,126)
  10. 从Go看,语言设计(一) (累计阅读 6,103)