diff --git a/go.mod b/go.mod index a76edf1..83f79af 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module ip_scan_go go 1.26.4 -require fyne.io/fyne/v2 v2.8.0 +require ( + fyne.io/fyne/v2 v2.8.0 + golang.org/x/net v0.35.0 +) require ( fyne.io/systray v1.12.2 // indirect @@ -36,7 +39,6 @@ require ( github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect golang.org/x/image v0.24.0 // indirect - golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/scan/dispatch.go b/internal/scan/dispatch.go new file mode 100644 index 0000000..89ea44b --- /dev/null +++ b/internal/scan/dispatch.go @@ -0,0 +1,141 @@ +package scan + +import ( + "strconv" + "sync" + "sync/atomic" + "time" +) + +type Result struct { + Text string +} + +type Progress struct { + Done int + Total int +} + +type Dispatcher struct { + IPs []string + Ports []int + Timeout time.Duration + Workers int + PingOnly bool + + OnResult func(Result) + OnProgress func(Progress) + OnFinish func() + + stop chan struct{} + done sync.WaitGroup + counter int64 +} + +func (d *Dispatcher) Start() { + d.stop = make(chan struct{}) + tasks := d.buildTasks() + total := len(tasks) + if total == 0 { + if d.OnFinish != nil { + d.OnFinish() + } + return + } + + workers := d.Workers + if workers <= 0 { + workers = 1 + } + + taskCh := make(chan task) + for i := 0; i < workers; i++ { + d.done.Add(1) + go d.worker(taskCh, total) + } + +feed: + for _, t := range tasks { + select { + case <-d.stop: + break feed + case taskCh <- t: + } + } + close(taskCh) + d.done.Wait() + if d.OnFinish != nil { + d.OnFinish() + } +} + +func (d *Dispatcher) worker(taskCh <-chan task, total int) { + defer d.done.Done() + + var pinger *Pinger + if d.PingOnly { + pinger, _ = NewPinger() + defer pinger.Close() + } + + for task := range taskCh { + select { + case <-d.stop: + return + default: + } + + ok := false + if task.ping { + if pinger != nil { + ok = pinger.Ping(task.ip, d.Timeout) + } + } else { + ok = TCPProbe(task.ip, task.port, d.Timeout) + } + + if ok && d.OnResult != nil { + d.OnResult(Result{Text: task.label}) + } + + done := int(atomic.AddInt64(&d.counter, 1)) + if d.OnProgress != nil { + d.OnProgress(Progress{Done: done, Total: total}) + } + } +} + +func (d *Dispatcher) Stop() { + select { + case <-d.stop: + default: + close(d.stop) + } +} + +type task struct { + ip string + port int + label string + ping bool +} + +func (d *Dispatcher) buildTasks() []task { + var tasks []task + if d.PingOnly { + for _, ip := range d.IPs { + tasks = append(tasks, task{ip: ip, label: ip, ping: true}) + } + } else { + for _, ip := range d.IPs { + for _, port := range d.Ports { + tasks = append(tasks, task{ + ip: ip, + port: port, + label: ip + ":" + strconv.Itoa(port), + }) + } + } + } + return tasks +} diff --git a/internal/scan/ping.go b/internal/scan/ping.go new file mode 100644 index 0000000..05a3dcb --- /dev/null +++ b/internal/scan/ping.go @@ -0,0 +1,108 @@ +package scan + +import ( + "fmt" + "net" + "sync/atomic" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +type Pinger struct { + conn *icmp.PacketConn + seq atomic.Uint32 +} + +func NewPinger() (*Pinger, error) { + conn, err := icmp.ListenPacket("udp4", "0.0.0.0") + if err != nil { + return nil, fmt.Errorf("icmp listen: %w", err) + } + return &Pinger{conn: conn}, nil +} + +func (p *Pinger) Close() { + if p.conn != nil { + p.conn.Close() + } +} + +func (p *Pinger) Ping(ip string, timeout time.Duration) bool { + dst, err := net.ResolveIPAddr("ip4", ip) + if err != nil { + return false + } + + var target net.Addr + switch p.conn.LocalAddr().(type) { + case *net.UDPAddr: + target = &net.UDPAddr{IP: dst.IP} + case *net.IPAddr: + target = &net.IPAddr{IP: dst.IP} + default: + target = dst + } + + seq := uint16(p.seq.Add(1)) + msg := icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ + ID: 0, + Seq: int(seq), + Data: []byte("PING"), + }, + } + buf, err := msg.Marshal(nil) + if err != nil { + return false + } + + if _, err := p.conn.WriteTo(buf, target); err != nil { + return false + } + + deadline := time.Now().Add(timeout) + rb := make([]byte, 1500) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return false + } + _ = p.conn.SetReadDeadline(time.Now().Add(remaining)) + n, peer, err := p.conn.ReadFrom(rb) + if err != nil { + return false + } + rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n]) + if err != nil { + continue + } + if rm.Type != ipv4.ICMPTypeEchoReply { + continue + } + var peerIP net.IP + switch a := peer.(type) { + case *net.UDPAddr: + peerIP = a.IP + case *net.IPAddr: + peerIP = a.IP + default: + peerIP = net.ParseIP(peer.String()) + } + if peerIP != nil && peerIP.Equal(dst.IP) { + return true + } + } +} + +func Ping(ip string, timeout time.Duration) (bool, error) { + p, err := NewPinger() + if err != nil { + return false, err + } + defer p.Close() + return p.Ping(ip, timeout), nil +} diff --git a/internal/scan/target.go b/internal/scan/target.go new file mode 100644 index 0000000..df34ea8 --- /dev/null +++ b/internal/scan/target.go @@ -0,0 +1,133 @@ +package scan + +import ( + "bufio" + "fmt" + "net" + "net/netip" + "regexp" + "strconv" + "strings" +) + +var ( + reIPv4 = regexp.MustCompile(`^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$`) + reIPv4Range = regexp.MustCompile(`^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)-((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$`) + reCIDR = regexp.MustCompile(`^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)/(0?[0-9]|1[0-9]|2[0-9]|3[0-2])$`) + reIPv6 = regexp.MustCompile(`^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$`) + reDomain = regexp.MustCompile(`^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$`) + rePort = regexp.MustCompile(`^(0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$`) +) + +func ParseIPs(text string) ([]string, error) { + var ips []string + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + switch { + case reIPv4.MatchString(line): + ips = append(ips, line) + case reIPv4Range.MatchString(line): + parts := strings.SplitN(line, "-", 2) + a, err := netip.ParseAddr(parts[0]) + if err != nil { + return nil, fmt.Errorf("无效IP段: %s", line) + } + b, err := netip.ParseAddr(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效IP段: %s", line) + } + ai := a.As4() + bi := b.As4() + aInt := uint32(ai[0])<<24 | uint32(ai[1])<<16 | uint32(ai[2])<<8 | uint32(ai[3]) + bInt := uint32(bi[0])<<24 | uint32(bi[1])<<16 | uint32(bi[2])<<8 | uint32(bi[3]) + if aInt > bInt { + aInt, bInt = bInt, aInt + } + for i := aInt; i <= bInt; i++ { + ip := netip.AddrFrom4([4]byte{byte(i >> 24), byte(i >> 16), byte(i >> 8), byte(i)}) + ips = append(ips, ip.String()) + } + case reCIDR.MatchString(line): + baseIP, ipNet, err := net.ParseCIDR(line) + if err != nil { + return nil, fmt.Errorf("无效CIDR: %s", line) + } + if baseIP.To4() == nil { + return nil, fmt.Errorf("仅支持IPv4 CIDR: %s", line) + } + ips = append(ips, cidrIPs(ipNet)...) + case reIPv6.MatchString(line): + ips = append(ips, line) + case reDomain.MatchString(line): + ips = append(ips, line) + default: + return nil, fmt.Errorf("无法识别的目标: %s", line) + } + } + return ips, sc.Err() +} + +func cidrIPs(ipNet *net.IPNet) []string { + var ips []string + for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); inc(ip) { + ips = append(ips, ip.String()) + } + if len(ips) > 0 { + ips = ips[1:] + } + if len(ips) > 0 { + ips = ips[:len(ips)-1] + } + return ips +} + +func inc(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} + +func ParsePorts(text string) ([]int, error) { + var ports []int + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + for _, seg := range strings.Split(line, ";") { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + if strings.Contains(seg, "-") { + parts := strings.SplitN(seg, "-", 2) + a, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + b, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil || a < 0 || a > 65535 || b < 0 || b > 65535 { + return nil, fmt.Errorf("无效端口范围: %s", seg) + } + if a > b { + a, b = b, a + } + for p := a; p <= b; p++ { + ports = append(ports, p) + } + } else { + if !rePort.MatchString(seg) { + return nil, fmt.Errorf("无效端口: %s", seg) + } + p, _ := strconv.Atoi(seg) + ports = append(ports, p) + } + } + } + return ports, sc.Err() +} diff --git a/internal/scan/tcp.go b/internal/scan/tcp.go new file mode 100644 index 0000000..a033f53 --- /dev/null +++ b/internal/scan/tcp.go @@ -0,0 +1,17 @@ +package scan + +import ( + "net" + "strconv" + "time" +) + +func TCPProbe(ip string, port int, timeout time.Duration) bool { + addr := net.JoinHostPort(ip, strconv.Itoa(port)) + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return false + } + conn.Close() + return true +} diff --git a/internal/ui/scanform.go b/internal/ui/scanform.go index b69b2a9..e5931e6 100644 --- a/internal/ui/scanform.go +++ b/internal/ui/scanform.go @@ -4,11 +4,15 @@ import ( "fmt" "net/url" "strconv" + "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" + + "ip_scan_go/internal/scan" ) const appVersion = "V0.0.1" @@ -31,6 +35,7 @@ type scanForm struct { scanBtn *widget.Button pingBtn *widget.Button keypadBtn []*widget.Button + dispatcher *scan.Dispatcher scanning bool } @@ -108,36 +113,108 @@ func (f *scanForm) buildControlRow() fyne.CanvasObject { func (f *scanForm) toggleScan() { if !f.scanning { - f.scanning = true - f.scanBtn.SetText("停止扫描") - f.pingBtn.Disable() - f.setInputsEnabled(false) - f.progress.SetValue(0) - f.output.Append("开始扫描... (TODO: 接入扫描逻辑)\n") + ips, err := scan.ParseIPs(f.ipList.Text) + if err != nil { + f.appendOutput("IP解析错误: " + err.Error() + "\n") + return + } + ports, err := scan.ParsePorts(f.portList.Text) + if err != nil { + f.appendOutput("端口解析错误: " + err.Error() + "\n") + return + } + if len(ips) == 0 || len(ports) == 0 { + f.appendOutput("请输入IP和端口\n") + return + } + f.startScan(false, ips, ports) } else { - f.scanning = false - f.scanBtn.SetText("开始扫描") - f.pingBtn.Enable() - f.setInputsEnabled(true) - f.output.Append("已停止扫描\n") + f.stopScan() } } func (f *scanForm) togglePing() { if !f.scanning { - f.scanning = true + ips, err := scan.ParseIPs(f.ipList.Text) + if err != nil { + f.appendOutput("IP解析错误: " + err.Error() + "\n") + return + } + if len(ips) == 0 { + f.appendOutput("请输入IP\n") + return + } + f.startScan(true, ips, nil) + } else { + f.stopScan() + } +} + +func (f *scanForm) startScan(pingOnly bool, ips []string, ports []int) { + f.scanning = true + if pingOnly { f.pingBtn.SetText("停止Ping") f.scanBtn.Disable() - f.setInputsEnabled(false) - f.progress.SetValue(0) - f.output.Append("开始Ping... (TODO: 接入扫描逻辑)\n") } else { - f.scanning = false - f.pingBtn.SetText("仅Ping") - f.scanBtn.Enable() - f.setInputsEnabled(true) - f.output.Append("已停止Ping\n") + f.scanBtn.SetText("停止扫描") + f.pingBtn.Disable() } + f.setInputsEnabled(false) + f.progress.SetValue(0) + + timeout := time.Duration(parseIntEntry(f.timeout, 100)) * time.Millisecond + workers := parseIntEntry(f.threads, 64) + mode := "扫描" + if pingOnly { + mode = "Ping" + } + f.appendOutput(fmt.Sprintf("开始%s (IP:%d 线程:%d 超时:%s)\n", mode, len(ips), workers, timeout)) + + d := &scan.Dispatcher{ + IPs: ips, + Ports: ports, + Timeout: timeout, + Workers: workers, + PingOnly: pingOnly, + OnResult: func(r scan.Result) { + fyne.Do(func() { + f.appendOutput(r.Text + "\n") + }) + }, + OnProgress: func(p scan.Progress) { + pct := float64(p.Done) / float64(p.Total) + fyne.Do(func() { + f.progress.SetValue(pct) + }) + }, + OnFinish: func() { + fyne.Do(func() { + f.resetAfterScan(pingOnly) + f.appendOutput(fmt.Sprintf("%s完成\n", mode)) + }) + }, + } + f.dispatcher = d + go d.Start() +} + +func (f *scanForm) stopScan() { + if f.dispatcher != nil { + f.dispatcher.Stop() + } +} + +func (f *scanForm) resetAfterScan(pingOnly bool) { + f.scanning = false + if pingOnly { + f.pingBtn.SetText("仅Ping") + } else { + f.scanBtn.SetText("开始扫描") + } + f.scanBtn.Enable() + f.pingBtn.Enable() + f.setInputsEnabled(true) + f.dispatcher = nil } func (f *scanForm) setInputsEnabled(enabled bool) { @@ -162,7 +239,14 @@ func (f *scanForm) setInputsEnabled(enabled bool) { } func (f *scanForm) saveOutput() { - f.output.Append("(TODO: 保存输出到文件)\n") + f.appendOutput("(TODO: 保存输出到文件)\n") +} + +func (f *scanForm) appendOutput(text string) { + f.output.Append(text) + f.output.CursorRow = strings.Count(f.output.Text, "\n") + f.output.CursorColumn = 0 + f.output.Refresh() } func (f *scanForm) openURL(urlStr string) { @@ -185,3 +269,11 @@ func newIntEntry(val, min, max int) *widget.Entry { } return e } + +func parseIntEntry(e *widget.Entry, fallback int) int { + n, err := strconv.Atoi(e.Text) + if err != nil || n <= 0 { + return fallback + } + return n +}