package tools import ( "bufio" "context" "fmt" "net" "os/exec" "regexp" "strconv" "strings" "time" ) var ( validHostRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`) pingReplyRegex = regexp.MustCompile(`(\d+)\s+bytes from\s+([^:]+):\s+icmp_seq=(\d+)\s+ttl=(\d+)\s+time=([\d.]+)\s+ms`) pingTimeoutRegex = regexp.MustCompile(`(?:no answer yet for icmp_seq=|Request timeout for icmp_seq\s+)(\d+)`) pingUnreachRegex = regexp.MustCompile(`From\s+([^\s:]+)\s+icmp_seq=(\d+)\s+(.*)`) pingStatRegex = regexp.MustCompile(`(\d+)\s+packets transmitted,\s+(\d+)\s+(?:packets\s+)?received,\s+([\d.]+)%\s+packet loss`) pingRttRegex = regexp.MustCompile(`rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)\s+ms`) mtrHopRegex = regexp.MustCompile(`^\s*(\d+)\.\|--\s+(\S+)\s+([\d.]+)%?\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)`) ) type Manager struct{} func NewManager() *Manager { return &Manager{} } func sanitizeTarget(target string) (string, error) { t := strings.TrimSpace(target) if t == "" { return "", fmt.Errorf("target host or IP is required") } if len(t) > 255 || !validHostRegex.MatchString(t) { return "", fmt.Errorf("invalid target host format") } return t, nil } // RunPing executes ICMP ping with custom parameters func (m *Manager) RunPing(ctx context.Context, req PingRequest) (*PingResponse, error) { target, err := sanitizeTarget(req.Target) if err != nil { return nil, err } count := req.Count if count <= 0 { count = 4 } if count > 30 { count = 30 } size := req.Size if size <= 0 { size = 56 } if size > 65500 { size = 65500 } timeout := req.Timeout if timeout <= 0 { timeout = 5 } if timeout > 30 { timeout = 30 } args := []string{ "-c", strconv.Itoa(count), "-s", strconv.Itoa(size), "-W", strconv.Itoa(timeout), } if req.Interface != "" && validHostRegex.MatchString(req.Interface) { args = append(args, "-I", req.Interface) } if req.DoNotFrag { args = append(args, "-M", "do") } args = append(args, target) cmd := exec.CommandContext(ctx, "ping", args...) outBytes, _ := cmd.CombinedOutput() outStr := string(outBytes) resp := &PingResponse{ Target: target, PacketsSent: count, Replies: []PingReply{}, RawOutput: outStr, Status: "timeout", } // Parse individual ping replies matches := pingReplyRegex.FindAllStringSubmatch(outStr, -1) for _, match := range matches { if len(match) >= 6 { bytes, _ := strconv.Atoi(match[1]) from := match[2] seq, _ := strconv.Atoi(match[3]) ttl, _ := strconv.Atoi(match[4]) timeMs, _ := strconv.ParseFloat(match[5], 64) resp.Replies = append(resp.Replies, PingReply{ Seq: seq, Bytes: bytes, From: from, TTL: ttl, TimeMs: timeMs, }) } } // Parse overall stats if statMatch := pingStatRegex.FindStringSubmatch(outStr); len(statMatch) >= 4 { resp.PacketsSent, _ = strconv.Atoi(statMatch[1]) resp.PacketsRecv, _ = strconv.Atoi(statMatch[2]) resp.PacketLossPct, _ = strconv.ParseFloat(statMatch[3], 64) } // Parse RTT min/avg/max/mdev if rttMatch := pingRttRegex.FindStringSubmatch(outStr); len(rttMatch) >= 5 { resp.MinRTT, _ = strconv.ParseFloat(rttMatch[1], 64) resp.AvgRTT, _ = strconv.ParseFloat(rttMatch[2], 64) resp.MaxRTT, _ = strconv.ParseFloat(rttMatch[3], 64) resp.MdevRTT, _ = strconv.ParseFloat(rttMatch[4], 64) } if resp.PacketsRecv == resp.PacketsSent && resp.PacketsSent > 0 { resp.Status = "ok" } else if resp.PacketsRecv > 0 { resp.Status = "partial" } else { resp.Status = "timeout" } return resp, nil } // StreamPing executes ICMP ping and streams real-time events line-by-line func (m *Manager) StreamPing(ctx context.Context, req PingRequest, onEvent func(PingStreamEvent)) error { target, err := sanitizeTarget(req.Target) if err != nil { return err } size := req.Size if size <= 0 { size = 56 } if size > 65500 { size = 65500 } timeout := req.Timeout if timeout <= 0 { timeout = 3 } if timeout > 30 { timeout = 30 } args := []string{ "-O", // report outstanding replies/timeouts in real time "-s", strconv.Itoa(size), "-W", strconv.Itoa(timeout), } if req.Count > 0 { args = append(args, "-c", strconv.Itoa(req.Count)) } if req.Interface != "" && validHostRegex.MatchString(req.Interface) { args = append(args, "-I", req.Interface) } if req.DoNotFrag { args = append(args, "-M", "do") } args = append(args, target) cmd := exec.CommandContext(ctx, "ping", args...) stdout, err := cmd.StdoutPipe() if err != nil { return err } cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { return err } packetsSent := 0 packetsRecv := 0 var minRTT, maxRTT, sumRTT float64 scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) if trimmed == "" { continue } if strings.HasPrefix(trimmed, "PING ") { onEvent(PingStreamEvent{ Type: "header", Raw: line, }) continue } // Match reply if match := pingReplyRegex.FindStringSubmatch(trimmed); len(match) >= 6 { bytes, _ := strconv.Atoi(match[1]) from := match[2] seq, _ := strconv.Atoi(match[3]) ttl, _ := strconv.Atoi(match[4]) timeMs, _ := strconv.ParseFloat(match[5], 64) packetsSent++ packetsRecv++ sumRTT += timeMs if packetsRecv == 1 || timeMs < minRTT { minRTT = timeMs } if timeMs > maxRTT { maxRTT = timeMs } avgRTT := sumRTT / float64(packetsRecv) lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent) onEvent(PingStreamEvent{ Type: "reply", Seq: seq, Bytes: bytes, From: from, TTL: ttl, TimeMs: timeMs, PacketsSent: packetsSent, PacketsRecv: packetsRecv, PacketLossPct: lossPct, MinRTT: minRTT, AvgRTT: avgRTT, MaxRTT: maxRTT, Raw: line, }) continue } // Match timeout (no answer yet for icmp_seq=X) if match := pingTimeoutRegex.FindStringSubmatch(trimmed); len(match) >= 2 { seq, _ := strconv.Atoi(match[1]) packetsSent++ lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent) avgRTT := 0.0 if packetsRecv > 0 { avgRTT = sumRTT / float64(packetsRecv) } onEvent(PingStreamEvent{ Type: "timeout", Seq: seq, PacketsSent: packetsSent, PacketsRecv: packetsRecv, PacketLossPct: lossPct, MinRTT: minRTT, AvgRTT: avgRTT, MaxRTT: maxRTT, Message: "Request timed out", Raw: line, }) continue } // Match ICMP unreach error if match := pingUnreachRegex.FindStringSubmatch(trimmed); len(match) >= 4 { from := match[1] seq, _ := strconv.Atoi(match[2]) msg := match[3] packetsSent++ lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent) avgRTT := 0.0 if packetsRecv > 0 { avgRTT = sumRTT / float64(packetsRecv) } onEvent(PingStreamEvent{ Type: "unreach", Seq: seq, From: from, PacketsSent: packetsSent, PacketsRecv: packetsRecv, PacketLossPct: lossPct, MinRTT: minRTT, AvgRTT: avgRTT, MaxRTT: maxRTT, Message: msg, Raw: line, }) continue } // Match overall stats summary if match := pingStatRegex.FindStringSubmatch(trimmed); len(match) >= 4 { sSent, _ := strconv.Atoi(match[1]) sRecv, _ := strconv.Atoi(match[2]) sLoss, _ := strconv.ParseFloat(match[3], 64) packetsSent = sSent packetsRecv = sRecv avgRTT := 0.0 if packetsRecv > 0 { avgRTT = sumRTT / float64(packetsRecv) } onEvent(PingStreamEvent{ Type: "stats", PacketsSent: packetsSent, PacketsRecv: packetsRecv, PacketLossPct: sLoss, MinRTT: minRTT, AvgRTT: avgRTT, MaxRTT: maxRTT, Raw: line, }) continue } // Match RTT min/avg/max/mdev if match := pingRttRegex.FindStringSubmatch(trimmed); len(match) >= 5 { sMin, _ := strconv.ParseFloat(match[1], 64) sAvg, _ := strconv.ParseFloat(match[2], 64) sMax, _ := strconv.ParseFloat(match[3], 64) sMdev, _ := strconv.ParseFloat(match[4], 64) lossPct := 0.0 if packetsSent > 0 { lossPct = float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent) } onEvent(PingStreamEvent{ Type: "stats", PacketsSent: packetsSent, PacketsRecv: packetsRecv, PacketLossPct: lossPct, MinRTT: sMin, AvgRTT: sAvg, MaxRTT: sMax, MdevRTT: sMdev, Raw: line, }) continue } // Generic line onEvent(PingStreamEvent{ Type: "raw", Raw: line, }) } _ = cmd.Wait() lossPct := 0.0 if packetsSent > 0 { lossPct = float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent) } avgRTT := 0.0 if packetsRecv > 0 { avgRTT = sumRTT / float64(packetsRecv) } onEvent(PingStreamEvent{ Type: "done", PacketsSent: packetsSent, PacketsRecv: packetsRecv, PacketLossPct: lossPct, MinRTT: minRTT, AvgRTT: avgRTT, MaxRTT: maxRTT, Raw: "Ping finished", }) return nil } // RunTraceroute traces the route to a target func (m *Manager) RunTraceroute(ctx context.Context, req TracerouteRequest) (*TracerouteResponse, error) { target, err := sanitizeTarget(req.Target) if err != nil { return nil, err } maxHops := req.MaxHops if maxHops <= 0 { maxHops = 15 } if maxHops > 30 { maxHops = 30 } args := []string{ "-m", strconv.Itoa(maxHops), "-q", "2", "-w", "1", } if !req.Resolve { args = append(args, "-n") } if req.Protocol == "icmp" { args = append(args, "-I") } if req.Interface != "" && validHostRegex.MatchString(req.Interface) { args = append(args, "-i", req.Interface) } args = append(args, target) cmd := exec.CommandContext(ctx, "traceroute", args...) outBytes, _ := cmd.CombinedOutput() outStr := string(outBytes) resp := &TracerouteResponse{ Target: target, Hops: []TracerouteHop{}, RawOutput: outStr, } lines := strings.Split(outStr, "\n") for i, line := range lines { if i == 0 { continue // skip header line } trimmed := strings.TrimSpace(line) if trimmed == "" { continue } fields := strings.Fields(trimmed) if len(fields) < 2 { continue } hopNum, err := strconv.Atoi(fields[0]) if err != nil { continue } if fields[1] == "*" { resp.Hops = append(resp.Hops, TracerouteHop{ Hop: hopNum, Host: "*", IP: "*", TimesMs: []string{"*"}, Status: "timeout", }) } else { hostOrIP := fields[1] ip := hostOrIP if len(fields) >= 3 && strings.HasPrefix(fields[2], "(") && strings.HasSuffix(fields[2], ")") { ip = strings.Trim(fields[2], "()") } times := make([]string, 0) for _, f := range fields[2:] { if f != "ms" && !strings.HasPrefix(f, "(") { times = append(times, f) } } resp.Hops = append(resp.Hops, TracerouteHop{ Hop: hopNum, Host: hostOrIP, IP: ip, TimesMs: times, Status: "ok", }) } } return resp, nil } // StreamTraceroute executes traceroute and streams hop events line-by-line func (m *Manager) StreamTraceroute(ctx context.Context, req TracerouteRequest, onEvent func(TracerouteStreamEvent)) error { target, err := sanitizeTarget(req.Target) if err != nil { return err } maxHops := req.MaxHops if maxHops <= 0 { maxHops = 15 } if maxHops > 30 { maxHops = 30 } args := []string{ "-m", strconv.Itoa(maxHops), "-q", "2", "-w", "1", } if !req.Resolve { args = append(args, "-n") } if req.Protocol == "icmp" { args = append(args, "-I") } if req.Interface != "" && validHostRegex.MatchString(req.Interface) { args = append(args, "-i", req.Interface) } args = append(args, target) cmd := exec.CommandContext(ctx, "traceroute", args...) stdout, err := cmd.StdoutPipe() if err != nil { return err } cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { return err } hopsCount := 0 scanner := bufio.NewScanner(stdout) isHeader := true for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) if trimmed == "" { continue } if isHeader { isHeader = false onEvent(TracerouteStreamEvent{ Type: "header", Raw: line, }) continue } fields := strings.Fields(trimmed) if len(fields) < 2 { continue } hopNum, err := strconv.Atoi(fields[0]) if err != nil { continue } hopsCount++ var hop TracerouteHop if fields[1] == "*" { hop = TracerouteHop{ Hop: hopNum, Host: "*", IP: "*", TimesMs: []string{"*"}, Status: "timeout", } } else { hostOrIP := fields[1] ip := hostOrIP if len(fields) >= 3 && strings.HasPrefix(fields[2], "(") && strings.HasSuffix(fields[2], ")") { ip = strings.Trim(fields[2], "()") } times := make([]string, 0) for _, f := range fields[2:] { if f != "ms" && !strings.HasPrefix(f, "(") { times = append(times, f) } } hop = TracerouteHop{ Hop: hopNum, Host: hostOrIP, IP: ip, TimesMs: times, Status: "ok", } } onEvent(TracerouteStreamEvent{ Type: "hop", Hop: &hop, HopsCount: hopsCount, Raw: line, }) } _ = cmd.Wait() onEvent(TracerouteStreamEvent{ Type: "done", HopsCount: hopsCount, Raw: "Traceroute finished", }) return nil } // RunDNSLookup performs DNS resolution via dig func (m *Manager) RunDNSLookup(ctx context.Context, req DNSRequest) (*DNSResponse, error) { domain, err := sanitizeTarget(req.Domain) if err != nil { return nil, err } qType := strings.ToUpper(strings.TrimSpace(req.Type)) if qType == "" { qType = "A" } args := []string{ "+noall", "+answer", "+stats", domain, qType, } if req.Server != "" { srv, sErr := sanitizeTarget(req.Server) if sErr == nil { args = append(args, "@"+srv) } } cmd := exec.CommandContext(ctx, "dig", args...) outBytes, _ := cmd.CombinedOutput() outStr := string(outBytes) resp := &DNSResponse{ Domain: domain, Type: qType, Server: req.Server, Records: []DNSRecord{}, RawOutput: outStr, } lines := strings.Split(outStr, "\n") for _, line := range lines { l := strings.TrimSpace(line) if l == "" { continue } if strings.HasPrefix(l, ";; Query time:") { parts := strings.Fields(l) if len(parts) >= 4 { resp.QueryTimeMs, _ = strconv.Atoi(parts[2]) } continue } if strings.HasPrefix(l, ";; SERVER:") { parts := strings.Fields(l) if len(parts) >= 3 { resp.Server = parts[2] } continue } if strings.HasPrefix(l, ";") { continue } fields := strings.Fields(l) if len(fields) >= 5 { ttl, _ := strconv.Atoi(fields[1]) resp.Records = append(resp.Records, DNSRecord{ Name: fields[0], TTL: ttl, Class: fields[2], Type: fields[3], Value: strings.Join(fields[4:], " "), }) } } return resp, nil } // RunPortProbe tests TCP port connectivity and measures latency func (m *Manager) RunPortProbe(ctx context.Context, req PortProbeRequest) (*PortProbeResponse, error) { target, err := sanitizeTarget(req.Target) if err != nil { return nil, err } if req.Port <= 0 || req.Port > 65535 { return nil, fmt.Errorf("invalid port number: must be between 1 and 65535") } timeoutSec := req.Timeout if timeoutSec <= 0 { timeoutSec = 3 } if timeoutSec > 10 { timeoutSec = 10 } serviceMap := map[int]string{ 21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 53: "DNS", 80: "HTTP", 110: "POP3", 143: "IMAP", 179: "BGP", 443: "HTTPS", 587: "Submission", 993: "IMAPS", 995: "POP3S", 1194: "OpenVPN", 3389: "RDP", 5060: "SIP", 8080: "HTTP-Alt", 8291: "Winbox (MikroTik)", 8443: "HTTPS-Alt", 8006: "Proxmox VE", } addr := net.JoinHostPort(target, strconv.Itoa(req.Port)) start := time.Now() d := net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second} conn, dialErr := d.DialContext(ctx, "tcp", addr) elapsed := float64(time.Since(start).Microseconds()) / 1000.0 resp := &PortProbeResponse{ Target: target, Port: req.Port, LatencyMs: elapsed, Service: serviceMap[req.Port], } if dialErr != nil { resp.Open = false resp.Error = dialErr.Error() resp.RawOutput = fmt.Sprintf("Failed to connect to %s: %v", addr, dialErr) } else { _ = conn.Close() resp.Open = true resp.RawOutput = fmt.Sprintf("Successfully connected to %s (%s) in %.2f ms (TCP handshake OK)", addr, resp.Service, elapsed) } return resp, nil } // RunMTR executes MTR (My Traceroute) report func (m *Manager) RunMTR(ctx context.Context, req MTRRequest) (*MTRResponse, error) { target, err := sanitizeTarget(req.Target) if err != nil { return nil, err } count := req.Count if count <= 0 { count = 3 } if count > 10 { count = 10 } cmd := exec.CommandContext(ctx, "mtr", "-n", "-r", "-c", strconv.Itoa(count), target) outBytes, _ := cmd.CombinedOutput() outStr := string(outBytes) resp := &MTRResponse{ Target: target, Hops: []MTRHop{}, RawOutput: outStr, } lines := strings.Split(outStr, "\n") for _, line := range lines { matches := mtrHopRegex.FindStringSubmatch(line) if len(matches) >= 10 { hopNum, _ := strconv.Atoi(matches[1]) host := matches[2] loss, _ := strconv.ParseFloat(matches[3], 64) sent, _ := strconv.Atoi(matches[4]) last, _ := strconv.ParseFloat(matches[5], 64) avg, _ := strconv.ParseFloat(matches[6], 64) best, _ := strconv.ParseFloat(matches[7], 64) wrst, _ := strconv.ParseFloat(matches[8], 64) stdev, _ := strconv.ParseFloat(matches[9], 64) resp.Hops = append(resp.Hops, MTRHop{ Hop: hopNum, Host: host, LossPct: loss, Sent: sent, LastMs: last, AvgMs: avg, BestMs: best, WrstMs: wrst, StDevMs: stdev, }) } } return resp, nil }