manager.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. package tools
  2. import (
  3. "context"
  4. "fmt"
  5. "net"
  6. "os/exec"
  7. "regexp"
  8. "strconv"
  9. "strings"
  10. "time"
  11. )
  12. var (
  13. validHostRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`)
  14. pingReplyRegex = regexp.MustCompile(`(\d+)\s+bytes from\s+([^:]+):\s+icmp_seq=(\d+)\s+ttl=(\d+)\s+time=([\d.]+)\s+ms`)
  15. pingStatRegex = regexp.MustCompile(`(\d+)\s+packets transmitted,\s+(\d+)\s+(?:packets\s+)?received,\s+([\d.]+)%\s+packet loss`)
  16. pingRttRegex = regexp.MustCompile(`rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)\s+ms`)
  17. mtrHopRegex = regexp.MustCompile(`^\s*(\d+)\.\|--\s+(\S+)\s+([\d.]+)%?\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)`)
  18. )
  19. type Manager struct{}
  20. func NewManager() *Manager {
  21. return &Manager{}
  22. }
  23. func sanitizeTarget(target string) (string, error) {
  24. t := strings.TrimSpace(target)
  25. if t == "" {
  26. return "", fmt.Errorf("target host or IP is required")
  27. }
  28. if len(t) > 255 || !validHostRegex.MatchString(t) {
  29. return "", fmt.Errorf("invalid target host format")
  30. }
  31. return t, nil
  32. }
  33. // RunPing executes ICMP ping with custom parameters
  34. func (m *Manager) RunPing(ctx context.Context, req PingRequest) (*PingResponse, error) {
  35. target, err := sanitizeTarget(req.Target)
  36. if err != nil {
  37. return nil, err
  38. }
  39. count := req.Count
  40. if count <= 0 {
  41. count = 4
  42. }
  43. if count > 30 {
  44. count = 30
  45. }
  46. size := req.Size
  47. if size <= 0 {
  48. size = 56
  49. }
  50. if size > 65500 {
  51. size = 65500
  52. }
  53. timeout := req.Timeout
  54. if timeout <= 0 {
  55. timeout = 5
  56. }
  57. if timeout > 30 {
  58. timeout = 30
  59. }
  60. args := []string{
  61. "-c", strconv.Itoa(count),
  62. "-s", strconv.Itoa(size),
  63. "-W", strconv.Itoa(timeout),
  64. }
  65. if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
  66. args = append(args, "-I", req.Interface)
  67. }
  68. if req.DoNotFrag {
  69. args = append(args, "-M", "do")
  70. }
  71. args = append(args, target)
  72. cmd := exec.CommandContext(ctx, "ping", args...)
  73. outBytes, _ := cmd.CombinedOutput()
  74. outStr := string(outBytes)
  75. resp := &PingResponse{
  76. Target: target,
  77. PacketsSent: count,
  78. Replies: []PingReply{},
  79. RawOutput: outStr,
  80. Status: "timeout",
  81. }
  82. // Parse individual ping replies
  83. matches := pingReplyRegex.FindAllStringSubmatch(outStr, -1)
  84. for _, match := range matches {
  85. if len(match) >= 6 {
  86. bytes, _ := strconv.Atoi(match[1])
  87. from := match[2]
  88. seq, _ := strconv.Atoi(match[3])
  89. ttl, _ := strconv.Atoi(match[4])
  90. timeMs, _ := strconv.ParseFloat(match[5], 64)
  91. resp.Replies = append(resp.Replies, PingReply{
  92. Seq: seq,
  93. Bytes: bytes,
  94. From: from,
  95. TTL: ttl,
  96. TimeMs: timeMs,
  97. })
  98. }
  99. }
  100. // Parse overall stats
  101. if statMatch := pingStatRegex.FindStringSubmatch(outStr); len(statMatch) >= 4 {
  102. resp.PacketsSent, _ = strconv.Atoi(statMatch[1])
  103. resp.PacketsRecv, _ = strconv.Atoi(statMatch[2])
  104. resp.PacketLossPct, _ = strconv.ParseFloat(statMatch[3], 64)
  105. }
  106. // Parse RTT min/avg/max/mdev
  107. if rttMatch := pingRttRegex.FindStringSubmatch(outStr); len(rttMatch) >= 5 {
  108. resp.MinRTT, _ = strconv.ParseFloat(rttMatch[1], 64)
  109. resp.AvgRTT, _ = strconv.ParseFloat(rttMatch[2], 64)
  110. resp.MaxRTT, _ = strconv.ParseFloat(rttMatch[3], 64)
  111. resp.MdevRTT, _ = strconv.ParseFloat(rttMatch[4], 64)
  112. }
  113. if resp.PacketsRecv == resp.PacketsSent && resp.PacketsSent > 0 {
  114. resp.Status = "ok"
  115. } else if resp.PacketsRecv > 0 {
  116. resp.Status = "partial"
  117. } else {
  118. resp.Status = "timeout"
  119. }
  120. return resp, nil
  121. }
  122. // RunTraceroute traces the route to a target
  123. func (m *Manager) RunTraceroute(ctx context.Context, req TracerouteRequest) (*TracerouteResponse, error) {
  124. target, err := sanitizeTarget(req.Target)
  125. if err != nil {
  126. return nil, err
  127. }
  128. maxHops := req.MaxHops
  129. if maxHops <= 0 {
  130. maxHops = 15
  131. }
  132. if maxHops > 30 {
  133. maxHops = 30
  134. }
  135. args := []string{
  136. "-m", strconv.Itoa(maxHops),
  137. "-q", "2",
  138. "-w", "1",
  139. }
  140. if !req.Resolve {
  141. args = append(args, "-n")
  142. }
  143. if req.Protocol == "icmp" {
  144. args = append(args, "-I")
  145. }
  146. if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
  147. args = append(args, "-i", req.Interface)
  148. }
  149. args = append(args, target)
  150. cmd := exec.CommandContext(ctx, "traceroute", args...)
  151. outBytes, _ := cmd.CombinedOutput()
  152. outStr := string(outBytes)
  153. resp := &TracerouteResponse{
  154. Target: target,
  155. Hops: []TracerouteHop{},
  156. RawOutput: outStr,
  157. }
  158. lines := strings.Split(outStr, "\n")
  159. for i, line := range lines {
  160. if i == 0 {
  161. continue // skip header line
  162. }
  163. trimmed := strings.TrimSpace(line)
  164. if trimmed == "" {
  165. continue
  166. }
  167. fields := strings.Fields(trimmed)
  168. if len(fields) < 2 {
  169. continue
  170. }
  171. hopNum, err := strconv.Atoi(fields[0])
  172. if err != nil {
  173. continue
  174. }
  175. if fields[1] == "*" {
  176. resp.Hops = append(resp.Hops, TracerouteHop{
  177. Hop: hopNum,
  178. Host: "*",
  179. IP: "*",
  180. TimesMs: []string{"*"},
  181. Status: "timeout",
  182. })
  183. } else {
  184. hostOrIP := fields[1]
  185. ip := hostOrIP
  186. if len(fields) >= 3 && strings.HasPrefix(fields[2], "(") && strings.HasSuffix(fields[2], ")") {
  187. ip = strings.Trim(fields[2], "()")
  188. }
  189. times := make([]string, 0)
  190. for _, f := range fields[2:] {
  191. if f != "ms" && !strings.HasPrefix(f, "(") {
  192. times = append(times, f)
  193. }
  194. }
  195. resp.Hops = append(resp.Hops, TracerouteHop{
  196. Hop: hopNum,
  197. Host: hostOrIP,
  198. IP: ip,
  199. TimesMs: times,
  200. Status: "ok",
  201. })
  202. }
  203. }
  204. return resp, nil
  205. }
  206. // RunDNSLookup performs DNS resolution via dig
  207. func (m *Manager) RunDNSLookup(ctx context.Context, req DNSRequest) (*DNSResponse, error) {
  208. domain, err := sanitizeTarget(req.Domain)
  209. if err != nil {
  210. return nil, err
  211. }
  212. qType := strings.ToUpper(strings.TrimSpace(req.Type))
  213. if qType == "" {
  214. qType = "A"
  215. }
  216. args := []string{
  217. "+noall", "+answer", "+stats",
  218. domain,
  219. qType,
  220. }
  221. if req.Server != "" {
  222. srv, sErr := sanitizeTarget(req.Server)
  223. if sErr == nil {
  224. args = append(args, "@"+srv)
  225. }
  226. }
  227. cmd := exec.CommandContext(ctx, "dig", args...)
  228. outBytes, _ := cmd.CombinedOutput()
  229. outStr := string(outBytes)
  230. resp := &DNSResponse{
  231. Domain: domain,
  232. Type: qType,
  233. Server: req.Server,
  234. Records: []DNSRecord{},
  235. RawOutput: outStr,
  236. }
  237. lines := strings.Split(outStr, "\n")
  238. for _, line := range lines {
  239. l := strings.TrimSpace(line)
  240. if l == "" {
  241. continue
  242. }
  243. if strings.HasPrefix(l, ";; Query time:") {
  244. parts := strings.Fields(l)
  245. if len(parts) >= 4 {
  246. resp.QueryTimeMs, _ = strconv.Atoi(parts[2])
  247. }
  248. continue
  249. }
  250. if strings.HasPrefix(l, ";; SERVER:") {
  251. parts := strings.Fields(l)
  252. if len(parts) >= 3 {
  253. resp.Server = parts[2]
  254. }
  255. continue
  256. }
  257. if strings.HasPrefix(l, ";") {
  258. continue
  259. }
  260. fields := strings.Fields(l)
  261. if len(fields) >= 5 {
  262. ttl, _ := strconv.Atoi(fields[1])
  263. resp.Records = append(resp.Records, DNSRecord{
  264. Name: fields[0],
  265. TTL: ttl,
  266. Class: fields[2],
  267. Type: fields[3],
  268. Value: strings.Join(fields[4:], " "),
  269. })
  270. }
  271. }
  272. return resp, nil
  273. }
  274. // RunPortProbe tests TCP port connectivity and measures latency
  275. func (m *Manager) RunPortProbe(ctx context.Context, req PortProbeRequest) (*PortProbeResponse, error) {
  276. target, err := sanitizeTarget(req.Target)
  277. if err != nil {
  278. return nil, err
  279. }
  280. if req.Port <= 0 || req.Port > 65535 {
  281. return nil, fmt.Errorf("invalid port number: must be between 1 and 65535")
  282. }
  283. timeoutSec := req.Timeout
  284. if timeoutSec <= 0 {
  285. timeoutSec = 3
  286. }
  287. if timeoutSec > 10 {
  288. timeoutSec = 10
  289. }
  290. serviceMap := map[int]string{
  291. 21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 53: "DNS",
  292. 80: "HTTP", 110: "POP3", 143: "IMAP", 179: "BGP", 443: "HTTPS",
  293. 587: "Submission", 993: "IMAPS", 995: "POP3S", 1194: "OpenVPN",
  294. 3389: "RDP", 5060: "SIP", 8080: "HTTP-Alt", 8291: "Winbox (MikroTik)",
  295. 8443: "HTTPS-Alt", 8006: "Proxmox VE",
  296. }
  297. addr := net.JoinHostPort(target, strconv.Itoa(req.Port))
  298. start := time.Now()
  299. d := net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
  300. conn, dialErr := d.DialContext(ctx, "tcp", addr)
  301. elapsed := float64(time.Since(start).Microseconds()) / 1000.0
  302. resp := &PortProbeResponse{
  303. Target: target,
  304. Port: req.Port,
  305. LatencyMs: elapsed,
  306. Service: serviceMap[req.Port],
  307. }
  308. if dialErr != nil {
  309. resp.Open = false
  310. resp.Error = dialErr.Error()
  311. resp.RawOutput = fmt.Sprintf("Failed to connect to %s: %v", addr, dialErr)
  312. } else {
  313. _ = conn.Close()
  314. resp.Open = true
  315. resp.RawOutput = fmt.Sprintf("Successfully connected to %s (%s) in %.2f ms (TCP handshake OK)", addr, resp.Service, elapsed)
  316. }
  317. return resp, nil
  318. }
  319. // RunMTR executes MTR (My Traceroute) report
  320. func (m *Manager) RunMTR(ctx context.Context, req MTRRequest) (*MTRResponse, error) {
  321. target, err := sanitizeTarget(req.Target)
  322. if err != nil {
  323. return nil, err
  324. }
  325. count := req.Count
  326. if count <= 0 {
  327. count = 3
  328. }
  329. if count > 10 {
  330. count = 10
  331. }
  332. cmd := exec.CommandContext(ctx, "mtr", "-n", "-r", "-c", strconv.Itoa(count), target)
  333. outBytes, _ := cmd.CombinedOutput()
  334. outStr := string(outBytes)
  335. resp := &MTRResponse{
  336. Target: target,
  337. Hops: []MTRHop{},
  338. RawOutput: outStr,
  339. }
  340. lines := strings.Split(outStr, "\n")
  341. for _, line := range lines {
  342. matches := mtrHopRegex.FindStringSubmatch(line)
  343. if len(matches) >= 10 {
  344. hopNum, _ := strconv.Atoi(matches[1])
  345. host := matches[2]
  346. loss, _ := strconv.ParseFloat(matches[3], 64)
  347. sent, _ := strconv.Atoi(matches[4])
  348. last, _ := strconv.ParseFloat(matches[5], 64)
  349. avg, _ := strconv.ParseFloat(matches[6], 64)
  350. best, _ := strconv.ParseFloat(matches[7], 64)
  351. wrst, _ := strconv.ParseFloat(matches[8], 64)
  352. stdev, _ := strconv.ParseFloat(matches[9], 64)
  353. resp.Hops = append(resp.Hops, MTRHop{
  354. Hop: hopNum,
  355. Host: host,
  356. LossPct: loss,
  357. Sent: sent,
  358. LastMs: last,
  359. AvgMs: avg,
  360. BestMs: best,
  361. WrstMs: wrst,
  362. StDevMs: stdev,
  363. })
  364. }
  365. }
  366. return resp, nil
  367. }