manager.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835
  1. package tools
  2. import (
  3. "bufio"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "net"
  8. "os/exec"
  9. "regexp"
  10. "strconv"
  11. "strings"
  12. "time"
  13. )
  14. var (
  15. validHostRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`)
  16. pingReplyRegex = regexp.MustCompile(`(\d+)\s+bytes from\s+([^:]+):\s+icmp_seq=(\d+)\s+ttl=(\d+)\s+time=([\d.]+)\s+ms`)
  17. pingTimeoutRegex = regexp.MustCompile(`(?:no answer yet for icmp_seq=|Request timeout for icmp_seq\s+)(\d+)`)
  18. pingUnreachRegex = regexp.MustCompile(`From\s+([^\s:]+)\s+icmp_seq=(\d+)\s+(.*)`)
  19. pingStatRegex = regexp.MustCompile(`(\d+)\s+packets transmitted,\s+(\d+)\s+(?:packets\s+)?received,\s+([\d.]+)%\s+packet loss`)
  20. pingRttRegex = regexp.MustCompile(`rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)\s+ms`)
  21. mtrHopRegex = regexp.MustCompile(`^\s*(\d+)\.\|--\s+(\S+)\s+([\d.]+)%?\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)`)
  22. )
  23. type Manager struct{}
  24. func NewManager() *Manager {
  25. return &Manager{}
  26. }
  27. func sanitizeTarget(target string) (string, error) {
  28. t := strings.TrimSpace(target)
  29. if t == "" {
  30. return "", fmt.Errorf("target host or IP is required")
  31. }
  32. if len(t) > 255 || !validHostRegex.MatchString(t) {
  33. return "", fmt.Errorf("invalid target host format")
  34. }
  35. return t, nil
  36. }
  37. // RunPing executes ICMP ping with custom parameters
  38. func (m *Manager) RunPing(ctx context.Context, req PingRequest) (*PingResponse, error) {
  39. target, err := sanitizeTarget(req.Target)
  40. if err != nil {
  41. return nil, err
  42. }
  43. count := req.Count
  44. if count <= 0 {
  45. count = 4
  46. }
  47. if count > 30 {
  48. count = 30
  49. }
  50. size := req.Size
  51. if size <= 0 {
  52. size = 56
  53. }
  54. if size > 65500 {
  55. size = 65500
  56. }
  57. timeout := req.Timeout
  58. if timeout <= 0 {
  59. timeout = 5
  60. }
  61. if timeout > 30 {
  62. timeout = 30
  63. }
  64. args := []string{
  65. "-c", strconv.Itoa(count),
  66. "-s", strconv.Itoa(size),
  67. "-W", strconv.Itoa(timeout),
  68. }
  69. if req.Interface != "" {
  70. if !validHostRegex.MatchString(req.Interface) {
  71. return nil, fmt.Errorf("invalid interface name")
  72. }
  73. args = append(args, "-I", req.Interface)
  74. }
  75. if req.DoNotFrag {
  76. args = append(args, "-M", "do")
  77. }
  78. args = append(args, target)
  79. cmd := exec.CommandContext(ctx, "ping", args...)
  80. outBytes, runErr := cmd.CombinedOutput()
  81. outStr := string(outBytes)
  82. if ctx.Err() != nil {
  83. return nil, ctx.Err()
  84. }
  85. if runErr != nil {
  86. var exitErr *exec.ExitError
  87. if !errors.As(runErr, &exitErr) || exitErr.ExitCode() != 1 {
  88. return nil, fmt.Errorf("ping failed: %s: %w", strings.TrimSpace(outStr), runErr)
  89. }
  90. }
  91. resp := &PingResponse{
  92. Target: target,
  93. PacketsSent: count,
  94. Replies: []PingReply{},
  95. RawOutput: outStr,
  96. Status: "timeout",
  97. }
  98. // Parse individual ping replies
  99. matches := pingReplyRegex.FindAllStringSubmatch(outStr, -1)
  100. for _, match := range matches {
  101. if len(match) >= 6 {
  102. bytes, _ := strconv.Atoi(match[1])
  103. from := match[2]
  104. seq, _ := strconv.Atoi(match[3])
  105. ttl, _ := strconv.Atoi(match[4])
  106. timeMs, _ := strconv.ParseFloat(match[5], 64)
  107. resp.Replies = append(resp.Replies, PingReply{
  108. Seq: seq,
  109. Bytes: bytes,
  110. From: from,
  111. TTL: ttl,
  112. TimeMs: timeMs,
  113. })
  114. }
  115. }
  116. // Parse overall stats
  117. if statMatch := pingStatRegex.FindStringSubmatch(outStr); len(statMatch) >= 4 {
  118. resp.PacketsSent, _ = strconv.Atoi(statMatch[1])
  119. resp.PacketsRecv, _ = strconv.Atoi(statMatch[2])
  120. resp.PacketLossPct, _ = strconv.ParseFloat(statMatch[3], 64)
  121. }
  122. // Parse RTT min/avg/max/mdev
  123. if rttMatch := pingRttRegex.FindStringSubmatch(outStr); len(rttMatch) >= 5 {
  124. resp.MinRTT, _ = strconv.ParseFloat(rttMatch[1], 64)
  125. resp.AvgRTT, _ = strconv.ParseFloat(rttMatch[2], 64)
  126. resp.MaxRTT, _ = strconv.ParseFloat(rttMatch[3], 64)
  127. resp.MdevRTT, _ = strconv.ParseFloat(rttMatch[4], 64)
  128. }
  129. if resp.PacketsRecv == resp.PacketsSent && resp.PacketsSent > 0 {
  130. resp.Status = "ok"
  131. } else if resp.PacketsRecv > 0 {
  132. resp.Status = "partial"
  133. } else {
  134. resp.Status = "timeout"
  135. }
  136. return resp, nil
  137. }
  138. // StreamPing executes ICMP ping and streams real-time events line-by-line
  139. func (m *Manager) StreamPing(ctx context.Context, req PingRequest, onEvent func(PingStreamEvent)) error {
  140. target, err := sanitizeTarget(req.Target)
  141. if err != nil {
  142. return err
  143. }
  144. size := req.Size
  145. if size <= 0 {
  146. size = 56
  147. }
  148. if size > 65500 {
  149. size = 65500
  150. }
  151. timeout := req.Timeout
  152. if timeout <= 0 {
  153. timeout = 3
  154. }
  155. if timeout > 30 {
  156. timeout = 30
  157. }
  158. args := []string{
  159. "-O", // report outstanding replies/timeouts in real time
  160. "-s", strconv.Itoa(size),
  161. "-W", strconv.Itoa(timeout),
  162. }
  163. if req.Count > 0 {
  164. args = append(args, "-c", strconv.Itoa(req.Count))
  165. }
  166. if req.Interface != "" {
  167. if !validHostRegex.MatchString(req.Interface) {
  168. return fmt.Errorf("invalid interface name")
  169. }
  170. args = append(args, "-I", req.Interface)
  171. }
  172. if req.DoNotFrag {
  173. args = append(args, "-M", "do")
  174. }
  175. args = append(args, target)
  176. cmd := exec.CommandContext(ctx, "ping", args...)
  177. stdout, err := cmd.StdoutPipe()
  178. if err != nil {
  179. return err
  180. }
  181. cmd.Stderr = cmd.Stdout
  182. if err := cmd.Start(); err != nil {
  183. return err
  184. }
  185. packetsSent := 0
  186. packetsRecv := 0
  187. var minRTT, maxRTT, sumRTT float64
  188. scanner := bufio.NewScanner(stdout)
  189. for scanner.Scan() {
  190. line := scanner.Text()
  191. trimmed := strings.TrimSpace(line)
  192. if trimmed == "" {
  193. continue
  194. }
  195. if strings.HasPrefix(trimmed, "PING ") {
  196. onEvent(PingStreamEvent{
  197. Type: "header",
  198. Raw: line,
  199. })
  200. continue
  201. }
  202. // Match reply
  203. if match := pingReplyRegex.FindStringSubmatch(trimmed); len(match) >= 6 {
  204. bytes, _ := strconv.Atoi(match[1])
  205. from := match[2]
  206. seq, _ := strconv.Atoi(match[3])
  207. ttl, _ := strconv.Atoi(match[4])
  208. timeMs, _ := strconv.ParseFloat(match[5], 64)
  209. packetsSent++
  210. packetsRecv++
  211. sumRTT += timeMs
  212. if packetsRecv == 1 || timeMs < minRTT {
  213. minRTT = timeMs
  214. }
  215. if timeMs > maxRTT {
  216. maxRTT = timeMs
  217. }
  218. avgRTT := sumRTT / float64(packetsRecv)
  219. lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
  220. onEvent(PingStreamEvent{
  221. Type: "reply",
  222. Seq: seq,
  223. Bytes: bytes,
  224. From: from,
  225. TTL: ttl,
  226. TimeMs: timeMs,
  227. PacketsSent: packetsSent,
  228. PacketsRecv: packetsRecv,
  229. PacketLossPct: lossPct,
  230. MinRTT: minRTT,
  231. AvgRTT: avgRTT,
  232. MaxRTT: maxRTT,
  233. Raw: line,
  234. })
  235. continue
  236. }
  237. // Match timeout (no answer yet for icmp_seq=X)
  238. if match := pingTimeoutRegex.FindStringSubmatch(trimmed); len(match) >= 2 {
  239. seq, _ := strconv.Atoi(match[1])
  240. packetsSent++
  241. lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
  242. avgRTT := 0.0
  243. if packetsRecv > 0 {
  244. avgRTT = sumRTT / float64(packetsRecv)
  245. }
  246. onEvent(PingStreamEvent{
  247. Type: "timeout",
  248. Seq: seq,
  249. PacketsSent: packetsSent,
  250. PacketsRecv: packetsRecv,
  251. PacketLossPct: lossPct,
  252. MinRTT: minRTT,
  253. AvgRTT: avgRTT,
  254. MaxRTT: maxRTT,
  255. Message: "Request timed out",
  256. Raw: line,
  257. })
  258. continue
  259. }
  260. // Match ICMP unreach error
  261. if match := pingUnreachRegex.FindStringSubmatch(trimmed); len(match) >= 4 {
  262. from := match[1]
  263. seq, _ := strconv.Atoi(match[2])
  264. msg := match[3]
  265. packetsSent++
  266. lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
  267. avgRTT := 0.0
  268. if packetsRecv > 0 {
  269. avgRTT = sumRTT / float64(packetsRecv)
  270. }
  271. onEvent(PingStreamEvent{
  272. Type: "unreach",
  273. Seq: seq,
  274. From: from,
  275. PacketsSent: packetsSent,
  276. PacketsRecv: packetsRecv,
  277. PacketLossPct: lossPct,
  278. MinRTT: minRTT,
  279. AvgRTT: avgRTT,
  280. MaxRTT: maxRTT,
  281. Message: msg,
  282. Raw: line,
  283. })
  284. continue
  285. }
  286. // Match overall stats summary
  287. if match := pingStatRegex.FindStringSubmatch(trimmed); len(match) >= 4 {
  288. sSent, _ := strconv.Atoi(match[1])
  289. sRecv, _ := strconv.Atoi(match[2])
  290. sLoss, _ := strconv.ParseFloat(match[3], 64)
  291. packetsSent = sSent
  292. packetsRecv = sRecv
  293. avgRTT := 0.0
  294. if packetsRecv > 0 {
  295. avgRTT = sumRTT / float64(packetsRecv)
  296. }
  297. onEvent(PingStreamEvent{
  298. Type: "stats",
  299. PacketsSent: packetsSent,
  300. PacketsRecv: packetsRecv,
  301. PacketLossPct: sLoss,
  302. MinRTT: minRTT,
  303. AvgRTT: avgRTT,
  304. MaxRTT: maxRTT,
  305. Raw: line,
  306. })
  307. continue
  308. }
  309. // Match RTT min/avg/max/mdev
  310. if match := pingRttRegex.FindStringSubmatch(trimmed); len(match) >= 5 {
  311. sMin, _ := strconv.ParseFloat(match[1], 64)
  312. sAvg, _ := strconv.ParseFloat(match[2], 64)
  313. sMax, _ := strconv.ParseFloat(match[3], 64)
  314. sMdev, _ := strconv.ParseFloat(match[4], 64)
  315. lossPct := 0.0
  316. if packetsSent > 0 {
  317. lossPct = float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
  318. }
  319. onEvent(PingStreamEvent{
  320. Type: "stats",
  321. PacketsSent: packetsSent,
  322. PacketsRecv: packetsRecv,
  323. PacketLossPct: lossPct,
  324. MinRTT: sMin,
  325. AvgRTT: sAvg,
  326. MaxRTT: sMax,
  327. MdevRTT: sMdev,
  328. Raw: line,
  329. })
  330. continue
  331. }
  332. // Generic line
  333. onEvent(PingStreamEvent{
  334. Type: "raw",
  335. Raw: line,
  336. })
  337. }
  338. scanErr := scanner.Err()
  339. waitErr := cmd.Wait()
  340. if scanErr != nil {
  341. return fmt.Errorf("reading ping output: %w", scanErr)
  342. }
  343. if ctx.Err() != nil {
  344. return ctx.Err()
  345. }
  346. if waitErr != nil {
  347. var exitErr *exec.ExitError
  348. if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != 1 {
  349. return fmt.Errorf("ping failed: %w", waitErr)
  350. }
  351. }
  352. lossPct := 0.0
  353. if packetsSent > 0 {
  354. lossPct = float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
  355. }
  356. avgRTT := 0.0
  357. if packetsRecv > 0 {
  358. avgRTT = sumRTT / float64(packetsRecv)
  359. }
  360. onEvent(PingStreamEvent{
  361. Type: "done",
  362. PacketsSent: packetsSent,
  363. PacketsRecv: packetsRecv,
  364. PacketLossPct: lossPct,
  365. MinRTT: minRTT,
  366. AvgRTT: avgRTT,
  367. MaxRTT: maxRTT,
  368. Raw: "Ping finished",
  369. })
  370. return nil
  371. }
  372. // RunTraceroute traces the route to a target
  373. func (m *Manager) RunTraceroute(ctx context.Context, req TracerouteRequest) (*TracerouteResponse, error) {
  374. target, err := sanitizeTarget(req.Target)
  375. if err != nil {
  376. return nil, err
  377. }
  378. maxHops := req.MaxHops
  379. if maxHops <= 0 {
  380. maxHops = 15
  381. }
  382. if maxHops > 30 {
  383. maxHops = 30
  384. }
  385. args := []string{
  386. "-m", strconv.Itoa(maxHops),
  387. "-q", "2",
  388. "-w", "1",
  389. }
  390. if !req.Resolve {
  391. args = append(args, "-n")
  392. }
  393. if req.Protocol == "icmp" {
  394. args = append(args, "-I")
  395. }
  396. if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
  397. args = append(args, "-i", req.Interface)
  398. }
  399. args = append(args, target)
  400. cmd := exec.CommandContext(ctx, "traceroute", args...)
  401. outBytes, _ := cmd.CombinedOutput()
  402. outStr := string(outBytes)
  403. resp := &TracerouteResponse{
  404. Target: target,
  405. Hops: []TracerouteHop{},
  406. RawOutput: outStr,
  407. }
  408. lines := strings.Split(outStr, "\n")
  409. for i, line := range lines {
  410. if i == 0 {
  411. continue // skip header line
  412. }
  413. trimmed := strings.TrimSpace(line)
  414. if trimmed == "" {
  415. continue
  416. }
  417. fields := strings.Fields(trimmed)
  418. if len(fields) < 2 {
  419. continue
  420. }
  421. hopNum, err := strconv.Atoi(fields[0])
  422. if err != nil {
  423. continue
  424. }
  425. if fields[1] == "*" {
  426. resp.Hops = append(resp.Hops, TracerouteHop{
  427. Hop: hopNum,
  428. Host: "*",
  429. IP: "*",
  430. TimesMs: []string{"*"},
  431. Status: "timeout",
  432. })
  433. } else {
  434. hostOrIP := fields[1]
  435. ip := hostOrIP
  436. if len(fields) >= 3 && strings.HasPrefix(fields[2], "(") && strings.HasSuffix(fields[2], ")") {
  437. ip = strings.Trim(fields[2], "()")
  438. }
  439. times := make([]string, 0)
  440. for _, f := range fields[2:] {
  441. if f != "ms" && !strings.HasPrefix(f, "(") {
  442. times = append(times, f)
  443. }
  444. }
  445. resp.Hops = append(resp.Hops, TracerouteHop{
  446. Hop: hopNum,
  447. Host: hostOrIP,
  448. IP: ip,
  449. TimesMs: times,
  450. Status: "ok",
  451. })
  452. }
  453. }
  454. return resp, nil
  455. }
  456. // StreamTraceroute executes traceroute and streams hop events line-by-line
  457. func (m *Manager) StreamTraceroute(ctx context.Context, req TracerouteRequest, onEvent func(TracerouteStreamEvent)) error {
  458. target, err := sanitizeTarget(req.Target)
  459. if err != nil {
  460. return err
  461. }
  462. maxHops := req.MaxHops
  463. if maxHops <= 0 {
  464. maxHops = 15
  465. }
  466. if maxHops > 30 {
  467. maxHops = 30
  468. }
  469. args := []string{
  470. "-m", strconv.Itoa(maxHops),
  471. "-q", "2",
  472. "-w", "1",
  473. }
  474. if !req.Resolve {
  475. args = append(args, "-n")
  476. }
  477. if req.Protocol == "icmp" {
  478. args = append(args, "-I")
  479. }
  480. if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
  481. args = append(args, "-i", req.Interface)
  482. }
  483. args = append(args, target)
  484. cmd := exec.CommandContext(ctx, "traceroute", args...)
  485. stdout, err := cmd.StdoutPipe()
  486. if err != nil {
  487. return err
  488. }
  489. cmd.Stderr = cmd.Stdout
  490. if err := cmd.Start(); err != nil {
  491. return err
  492. }
  493. hopsCount := 0
  494. scanner := bufio.NewScanner(stdout)
  495. isHeader := true
  496. for scanner.Scan() {
  497. line := scanner.Text()
  498. trimmed := strings.TrimSpace(line)
  499. if trimmed == "" {
  500. continue
  501. }
  502. if isHeader {
  503. isHeader = false
  504. onEvent(TracerouteStreamEvent{
  505. Type: "header",
  506. Raw: line,
  507. })
  508. continue
  509. }
  510. fields := strings.Fields(trimmed)
  511. if len(fields) < 2 {
  512. continue
  513. }
  514. hopNum, err := strconv.Atoi(fields[0])
  515. if err != nil {
  516. continue
  517. }
  518. hopsCount++
  519. var hop TracerouteHop
  520. if fields[1] == "*" {
  521. hop = TracerouteHop{
  522. Hop: hopNum,
  523. Host: "*",
  524. IP: "*",
  525. TimesMs: []string{"*"},
  526. Status: "timeout",
  527. }
  528. } else {
  529. hostOrIP := fields[1]
  530. ip := hostOrIP
  531. if len(fields) >= 3 && strings.HasPrefix(fields[2], "(") && strings.HasSuffix(fields[2], ")") {
  532. ip = strings.Trim(fields[2], "()")
  533. }
  534. times := make([]string, 0)
  535. for _, f := range fields[2:] {
  536. if f != "ms" && !strings.HasPrefix(f, "(") {
  537. times = append(times, f)
  538. }
  539. }
  540. hop = TracerouteHop{
  541. Hop: hopNum,
  542. Host: hostOrIP,
  543. IP: ip,
  544. TimesMs: times,
  545. Status: "ok",
  546. }
  547. }
  548. onEvent(TracerouteStreamEvent{
  549. Type: "hop",
  550. Hop: &hop,
  551. HopsCount: hopsCount,
  552. Raw: line,
  553. })
  554. }
  555. _ = cmd.Wait()
  556. onEvent(TracerouteStreamEvent{
  557. Type: "done",
  558. HopsCount: hopsCount,
  559. Raw: "Traceroute finished",
  560. })
  561. return nil
  562. }
  563. // RunDNSLookup performs DNS resolution via dig
  564. func (m *Manager) RunDNSLookup(ctx context.Context, req DNSRequest) (*DNSResponse, error) {
  565. domain, err := sanitizeTarget(req.Domain)
  566. if err != nil {
  567. return nil, err
  568. }
  569. qType := strings.ToUpper(strings.TrimSpace(req.Type))
  570. if qType == "" {
  571. qType = "A"
  572. }
  573. args := []string{
  574. "+noall", "+answer", "+stats",
  575. domain,
  576. qType,
  577. }
  578. if req.Server != "" {
  579. srv, sErr := sanitizeTarget(req.Server)
  580. if sErr == nil {
  581. args = append(args, "@"+srv)
  582. }
  583. }
  584. cmd := exec.CommandContext(ctx, "dig", args...)
  585. outBytes, _ := cmd.CombinedOutput()
  586. outStr := string(outBytes)
  587. resp := &DNSResponse{
  588. Domain: domain,
  589. Type: qType,
  590. Server: req.Server,
  591. Records: []DNSRecord{},
  592. RawOutput: outStr,
  593. }
  594. lines := strings.Split(outStr, "\n")
  595. for _, line := range lines {
  596. l := strings.TrimSpace(line)
  597. if l == "" {
  598. continue
  599. }
  600. if strings.HasPrefix(l, ";; Query time:") {
  601. parts := strings.Fields(l)
  602. if len(parts) >= 4 {
  603. resp.QueryTimeMs, _ = strconv.Atoi(parts[2])
  604. }
  605. continue
  606. }
  607. if strings.HasPrefix(l, ";; SERVER:") {
  608. parts := strings.Fields(l)
  609. if len(parts) >= 3 {
  610. resp.Server = parts[2]
  611. }
  612. continue
  613. }
  614. if strings.HasPrefix(l, ";") {
  615. continue
  616. }
  617. fields := strings.Fields(l)
  618. if len(fields) >= 5 {
  619. ttl, _ := strconv.Atoi(fields[1])
  620. resp.Records = append(resp.Records, DNSRecord{
  621. Name: fields[0],
  622. TTL: ttl,
  623. Class: fields[2],
  624. Type: fields[3],
  625. Value: strings.Join(fields[4:], " "),
  626. })
  627. }
  628. }
  629. return resp, nil
  630. }
  631. // RunPortProbe tests TCP port connectivity and measures latency
  632. func (m *Manager) RunPortProbe(ctx context.Context, req PortProbeRequest) (*PortProbeResponse, error) {
  633. target, err := sanitizeTarget(req.Target)
  634. if err != nil {
  635. return nil, err
  636. }
  637. if req.Port <= 0 || req.Port > 65535 {
  638. return nil, fmt.Errorf("invalid port number: must be between 1 and 65535")
  639. }
  640. timeoutSec := req.Timeout
  641. if timeoutSec <= 0 {
  642. timeoutSec = 3
  643. }
  644. if timeoutSec > 10 {
  645. timeoutSec = 10
  646. }
  647. serviceMap := map[int]string{
  648. 21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 53: "DNS",
  649. 80: "HTTP", 110: "POP3", 143: "IMAP", 179: "BGP", 443: "HTTPS",
  650. 587: "Submission", 993: "IMAPS", 995: "POP3S", 1194: "OpenVPN",
  651. 3389: "RDP", 5060: "SIP", 8080: "HTTP-Alt", 8291: "Winbox (MikroTik)",
  652. 8443: "HTTPS-Alt", 8006: "Proxmox VE",
  653. }
  654. addr := net.JoinHostPort(target, strconv.Itoa(req.Port))
  655. start := time.Now()
  656. d := net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
  657. conn, dialErr := d.DialContext(ctx, "tcp", addr)
  658. elapsed := float64(time.Since(start).Microseconds()) / 1000.0
  659. resp := &PortProbeResponse{
  660. Target: target,
  661. Port: req.Port,
  662. LatencyMs: elapsed,
  663. Service: serviceMap[req.Port],
  664. }
  665. if dialErr != nil {
  666. resp.Open = false
  667. resp.Error = dialErr.Error()
  668. resp.RawOutput = fmt.Sprintf("Failed to connect to %s: %v", addr, dialErr)
  669. } else {
  670. _ = conn.Close()
  671. resp.Open = true
  672. resp.RawOutput = fmt.Sprintf("Successfully connected to %s (%s) in %.2f ms (TCP handshake OK)", addr, resp.Service, elapsed)
  673. }
  674. return resp, nil
  675. }
  676. // RunMTR executes MTR (My Traceroute) report
  677. func (m *Manager) RunMTR(ctx context.Context, req MTRRequest) (*MTRResponse, error) {
  678. target, err := sanitizeTarget(req.Target)
  679. if err != nil {
  680. return nil, err
  681. }
  682. count := req.Count
  683. if count <= 0 {
  684. count = 3
  685. }
  686. if count > 10 {
  687. count = 10
  688. }
  689. cmd := exec.CommandContext(ctx, "mtr", "-n", "-r", "-c", strconv.Itoa(count), target)
  690. outBytes, _ := cmd.CombinedOutput()
  691. outStr := string(outBytes)
  692. resp := &MTRResponse{
  693. Target: target,
  694. Hops: []MTRHop{},
  695. RawOutput: outStr,
  696. }
  697. lines := strings.Split(outStr, "\n")
  698. for _, line := range lines {
  699. matches := mtrHopRegex.FindStringSubmatch(line)
  700. if len(matches) >= 10 {
  701. hopNum, _ := strconv.Atoi(matches[1])
  702. host := matches[2]
  703. loss, _ := strconv.ParseFloat(matches[3], 64)
  704. sent, _ := strconv.Atoi(matches[4])
  705. last, _ := strconv.ParseFloat(matches[5], 64)
  706. avg, _ := strconv.ParseFloat(matches[6], 64)
  707. best, _ := strconv.ParseFloat(matches[7], 64)
  708. wrst, _ := strconv.ParseFloat(matches[8], 64)
  709. stdev, _ := strconv.ParseFloat(matches[9], 64)
  710. resp.Hops = append(resp.Hops, MTRHop{
  711. Hop: hopNum,
  712. Host: host,
  713. LossPct: loss,
  714. Sent: sent,
  715. LastMs: last,
  716. AvgMs: avg,
  717. BestMs: best,
  718. WrstMs: wrst,
  719. StDevMs: stdev,
  720. })
  721. }
  722. }
  723. return resp, nil
  724. }