用Go手撸一个简单的端口扫描工具

最近在搞一个开放的云和边缘拨测平台,需要用到端口扫描工具,开源的闭源的其实很多,nmap 也挺好用。但这些应用都不太满足需求,而且自己最近的确有点手痒,想撸一会儿代码了。首要的需求还是要兼容尽量多的平台和操作系统,其次就是不能有太多的外部库依赖,理所当然的就使用go开撸了。

本文分享的代码已经在并发和内存占用上做了优化,可根据工况调整参数,编译后可以作为一个跨平台的高性能端口扫描工具来使用。需要注意的是,该工具仅用于编程学习,切勿用于非法工况或事务。

使用方法

扫描时会将开放的端口写入到 results.txt 文件中,如果无可用端口此文件将为空。假设编译后的二进制文件名为 portscan,使用方法如下:

  • portscan 100 1.1.1.1 2.2.2.2,以100并发端口的速率扫描两个ip地址
  • portscan 100,将从 ips.txt 读取列表,并以100并发端口的速率扫描这些ip地址

完整代码

作者有点懒,重要的信息都写注释里了。如果对go不太熟悉,可参阅文末来自 Github Copilot 的代码分析。

// Package name: portscan
// Author: Rehiy<https://www.rehiy.com/post/559/>
package main

import (
    "bufio"
    "fmt"
    "net"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

var dialTimeout = 3
var concurrency = 100

func main() {
    var err error
    var ips []string

    // 如果存在第一个命令行参数,使用这个参数作为并发数
    if len(os.Args) > 1 {
        concurrency, err = strconv.Atoi(os.Args[1])
        if err != nil {
            fmt.Println("Error: concurrency must be an integer")
            os.Exit(1)
        }
    }

    // 如果存在更多的命令行参数,使用这些参数作为 IP 地址
    // 否则,从 ips.txt 文件中读取 IP 列表
    if len(os.Args) > 2 {
        for _, ip := range os.Args[2:] {
            if net.ParseIP(ip) == nil {
                fmt.Println("Error: Invalid IP address", ip)
                os.Exit(1)
            }
        }
        ips = os.Args[2:]
    } else {
        ips, err = readIPs("ips.txt")
        if err != nil {
            fmt.Println("Error reading IP list:", err)
            os.Exit(1)
        }
    }

    // 创建一个带有缓冲区的 channel 来存储结果
    // 在另一个 goroutine 中从 channel 中读取结果并写入文件
    results := make(chan string, 100)
    defer close(results)
    go func() {
        file, err := os.OpenFile("results.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
        if err != nil {
            fmt.Println("Error opening file:", err)
            os.Exit(1)
        }
        defer file.Close()
        for result := range results {
            if _, err := file.WriteString(result); err != nil {
                fmt.Println("Error writing to file:", err)
            }
        }
    }()

    // 遍历 IP 列表,对每个 IP 进行端口扫描
    // 使用 WaitGroup 和信号量来控制并发数
    var wg sync.WaitGroup
    sem := make(chan bool, concurrency)
    for _, ip := range ips {
        for port := 1; port <= 65535; port++ {
            wg.Add(1)
            sem <- true
            go func(ip string, port int) {
                defer wg.Done()
                defer func() { <-sem }()
                addr := buildAddr(ip, port)
                if testPort(addr, "tcp") {
                    // 将结果发送到 channel
                    results <- addr + "\n"
                }
            }(ip, port)
        }
    }
    wg.Wait()
}

func readIPs(filename string) ([]string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    var ips []string
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        ips = append(ips, scanner.Text())
    }

    if err := scanner.Err(); err != nil {
        return nil, err
    }

    return ips, nil
}

func testPort(addr, network string) bool {
    s := time.Duration(dialTimeout) * time.Second
    if conn, err := net.DialTimeout(network, addr, s); err == nil {
        conn.Close() // 关闭连接,释放资源
        fmt.Printf("%s is open\n", addr)
        return true
    }
    return false
}

func buildAddr(ip string, port int) string {
    var addr string
    if strings.Contains(ip, ":") {
        addr = fmt.Sprintf("[%s]:%d", ip, port)
    } else {
        addr = fmt.Sprintf("%s:%d", ip, port)
    }
    return addr
}

代码分析

这段代码的主要目的是从命令行参数或文件中获取 IP 地址列表,并设置并发数,然后对这些 IP 地址进行端口扫描。

  1. 导入了一些必要的包,包括用于网络编程、文件操作、字符串处理、并发控制等的包。

  2. 定义了两个全局变量:dialTimeoutconcurrencydialTimeout 用于设置连接超时时间,concurrency 用于设置并发数。

  3. main 函数中,首先定义了一个错误变量 err 和一个字符串切片 ips

  4. 检查命令行参数。如果存在第一个命令行参数,将其转换为整数并设置为并发数。如果转换失败,打印错误信息并退出程序。

  5. 如果存在更多的命令行参数,将这些参数作为 IP 地址。对每个 IP 地址进行解析,如果解析失败,打印错误信息并退出程序。

改进方向

这段代码使用了 goroutine 和信号量来并发地扫描每个 IP 地址的所有端口。并发数可以通过命令行参数来设置,这使得代码可以根据系统资源来调整并发数,提高扫描效率。然而,也有一些可以改进的地方:

  • 参数解析:代码使用了 os.Args 来解析命令行参数,这种方式比较原始,不支持参数说明、默认值等功能。可以考虑使用像 flag 这样的库来改进参数解析。

  • 结果输出:代码将所有的结果都写入同一个文件,如果扫描的 IP 地址很多,结果文件可能会很大。可以考虑将结果分别写入不同的文件,或者提供一个选项来控制是否将结果写入文件。

  • 性能优化:代码在扫描每个端口时都会创建一个新的 goroutine,这可能会导致 goroutine 的数量过多。可以考虑使用 goroutine 池来复用 goroutine,减少 goroutine 的创建和销毁开销。

  • 错误提示:代码在出现错误时会直接退出程序,这可能会导致用户不清楚发生了什么错误。可以考虑改进错误提示,例如打印更详细的错误信息,或者在出现错误时不退出程序,而是跳过当前的 IP 地址或端口,继续扫描其他的 IP 地址或端口。

文章作者: 若海; 原文链接: https://www.rehiy.com/post/559/; 转载需声明来自技术写真 - 若海

添加新评论