| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806 |
- 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
- }
|