摘要:自从加入百度负责物理网络的监控业务之后,我大部分的都是编写各种各样额度底层的网络程序。业余时间我也是编写一些有趣的网络程序,不仅仅是兴趣,也是为未来的某个业务探索一下技术方案。
自从加入百度负责物理网络的监控业务之后,我大部分的都是编写各种各样额度底层的网络程序。业余时间我也是编写一些有趣的网络程序,不仅仅是兴趣,也是为未来的某个业务探索一下技术方案。
而且这次,我想知道,就在我这一个 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 并发编程。 如果你对网络编程感兴趣,今年我还想推出《深入理解网络编程》的专栏或者图书,如果你感兴趣,欢迎和我探讨。
主程序的代码如下:
package mainimport ( "flag" "time" "github.com/kataras/golog")var ( protocol = flag.String("p", "icmp", "The protocol to use (icmp, TCP or udp)"))// 嵌入ip.shfunc 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接下来介绍三个三个主要 goroutine 的逻辑。
首先你需要到互联网管理中心下载中国大陆所有的注册的 IP 网段,这是从亚太互联网络信息中心下载的公网 IP 信息,实际上可以探测全球的 IP,这里以中国大陆的公网 IP 为例。
通过下面的代码转换成网段信息:
ipv4.txt 文件中是一行行的网段:
1.0.1.0/241.0.2.0/231.0.8.0/211.0.32.0/191.1.0.0/241.1.2.0/231.1.4.0/22...数据量不大,我们全读取进来(如果太多的话我们就流式读取了)。 解析每一行的网段,转换成 IP 地址列表,然后发送给 input 通道。 等处理完就把 inpout 通道关闭。
go func { lines := readIPList for _, line := range lines { ips := cidr2IPList(line) input我使用了ICMPScanner结构体来管理发送和接收的逻辑。看名字你也可以猜测到我们将来还可以使用 TCP/UDP 等协议进行探测。
type ICMPScanner struct { src net.IP input chan string output chan string}// 调大缓存区// sysctl net.core.rmem_max// sysctl net.core.wmem_maxfunc 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 包。
// 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 共享,目前的设计还是发送和接收使用各自的连接。
接下来就是发送的逻辑了,也就是上面省略的部分:
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 执行以下操作:解析 IP 地址构造 ICMP echo 请求报文序列化报文发送到目标地址icmp 报文中的 ID 我们设置为进程的 PID,在接收的时候可以用来判断是否是我们发送的回包。
接收逻辑比较简单,我们只需要接收 ICMP 回包,然后解析出 IP 地址,然后发送到 output 通道。
首先我们创建一个 ICMP 连接,然后循环接收 ICMP 回包,解析出 IP 地址,然后发送到 output 通道。
我们只需处理 ICMPTypeEchoReply 类型的回包,然后判断 ID 是否是我们发送的 ID,如果是就把对端的 IP 发送到 output 通道。
我们通过 ID 判断回包针对我们的场景就足够了,不用再判断 seq 甚至 payload 信息。
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对了,下面是我运行这个程序的输出:
...[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来源:散文随风想