package tools import ( "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`) 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 } // 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 } // 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 }