├── .gitignore ├── build.sh ├── dashboard.go ├── dashboardIncludes.go ├── diskPersist.go ├── linux └── duck ├── mac └── duck ├── main.go ├── readme.md ├── types.go └── windows └── duck.exe /.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | /duck 3 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | GOOS=darwin go build -o mac/duck 2 | GOOS=windows go build -o windows/duck.exe 3 | GOOS=linux go build -o linux/duck 4 | -------------------------------------------------------------------------------- /dashboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/gorilla/websocket" 9 | ) 10 | 11 | var dashboard string = fmt.Sprintf(` 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Duck Latency Tester 24 | 416 | 417 | 418 |
419 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 | 428 | 429 | `, jquery, boostrapCSS, bootstrapJS, bootstrapSlateCSS, highchartsJS, highchartsMoreJS, highchartsDarkUnicaThemeJS) 430 | 431 | func wsHandler(w http.ResponseWriter, req *http.Request) { 432 | ws, err := wsUpgrader.Upgrade(w, req, nil) 433 | if err != nil { 434 | elog.Print(err) 435 | return 436 | } 437 | 438 | c := &wsClient{ 439 | send: make(chan []byte, maxMessageSize), 440 | ws: ws, 441 | } 442 | 443 | wsHub.register <- c 444 | 445 | go c.writePump() 446 | go c.readPump() 447 | } 448 | 449 | func historyHandler(w http.ResponseWriter, req *http.Request) { 450 | w.Header().Add("Content-Type", "application/json") 451 | 452 | resultRequestChan <- 1 453 | historyJSON := <-resultResponseChan 454 | 455 | w.Write(historyJSON) 456 | } 457 | 458 | func baseHandler(w http.ResponseWriter, req *http.Request) { 459 | w.Header().Add("Content-Type", "text/html") 460 | w.Write([]byte(dashboard)) 461 | } 462 | 463 | const ( 464 | writeWait = 10 * time.Second 465 | pongWait = 60 * time.Second 466 | pingPeriod = (pongWait * 9) / 10 467 | maxMessageSize = 1024 * 1024 468 | ) 469 | 470 | var wsUpgrader = websocket.Upgrader{ 471 | ReadBufferSize: 1024, 472 | WriteBufferSize: 1024, 473 | } 474 | 475 | type hub struct { 476 | clients map[*wsClient]bool 477 | broadcast chan string 478 | register chan *wsClient 479 | unregister chan *wsClient 480 | 481 | content string 482 | } 483 | 484 | type wsClient struct { 485 | ws *websocket.Conn 486 | send chan []byte 487 | } 488 | 489 | var wsHub = hub{ 490 | broadcast: make(chan string), 491 | register: make(chan *wsClient), 492 | unregister: make(chan *wsClient), 493 | clients: make(map[*wsClient]bool), 494 | content: "", 495 | } 496 | 497 | func (h *hub) run() { 498 | for { 499 | select { 500 | case c := <-h.register: 501 | h.clients[c] = true 502 | c.send <- []byte(h.content) 503 | break 504 | 505 | case c := <-h.unregister: 506 | _, ok := h.clients[c] 507 | if ok { 508 | delete(h.clients, c) 509 | close(c.send) 510 | } 511 | break 512 | 513 | case m := <-h.broadcast: 514 | h.content = m 515 | h.broadcastMessage() 516 | break 517 | } 518 | } 519 | } 520 | 521 | func (h *hub) broadcastMessage() { 522 | for c := range h.clients { 523 | select { 524 | case c.send <- []byte(h.content): 525 | break 526 | 527 | // We can't reach the client 528 | default: 529 | close(c.send) 530 | delete(h.clients, c) 531 | } 532 | } 533 | } 534 | 535 | func (c *wsClient) readPump() { 536 | defer func() { 537 | wsHub.unregister <- c 538 | c.ws.Close() 539 | }() 540 | 541 | c.ws.SetReadLimit(maxMessageSize) 542 | c.ws.SetReadDeadline(time.Now().Add(pongWait)) 543 | c.ws.SetPongHandler(func(string) error { 544 | c.ws.SetReadDeadline(time.Now().Add(pongWait)) 545 | return nil 546 | }) 547 | 548 | for { 549 | _, message, err := c.ws.ReadMessage() 550 | if err != nil { 551 | break 552 | } 553 | 554 | wsHub.broadcast <- string(message) 555 | } 556 | } 557 | 558 | func (c *wsClient) writePump() { 559 | ticker := time.NewTicker(pingPeriod) 560 | 561 | defer func() { 562 | ticker.Stop() 563 | c.ws.Close() 564 | }() 565 | 566 | for { 567 | select { 568 | case message, ok := <-c.send: 569 | if !ok { 570 | c.write(websocket.CloseMessage, []byte{}) 571 | return 572 | } 573 | if err := c.write(websocket.TextMessage, message); err != nil { 574 | return 575 | } 576 | case <-ticker.C: 577 | if err := c.write(websocket.PingMessage, []byte{}); err != nil { 578 | return 579 | } 580 | } 581 | } 582 | } 583 | 584 | func (c *wsClient) write(mt int, message []byte) error { 585 | c.ws.SetWriteDeadline(time.Now().Add(writeWait)) 586 | return c.ws.WriteMessage(mt, message) 587 | } 588 | -------------------------------------------------------------------------------- /diskPersist.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | ) 11 | 12 | func saveProcessor() { 13 | 14 | saveTicker := time.NewTicker(time.Minute) 15 | 16 | for { 17 | select { 18 | case <-saveTicker.C: 19 | err := saveDataToDisk("pings", pingResults) 20 | if err != nil { 21 | log.Print(err) 22 | } 23 | 24 | pathResultsRequestChan <- 1 25 | prr := <-pathResultsResponseChan 26 | 27 | err = saveDataToDisk("paths", prr) 28 | if err != nil { 29 | log.Print(err) 30 | } 31 | 32 | pathsResult := PathsResult{ 33 | Paths: prr, 34 | MessageType: "paths", 35 | } 36 | 37 | pathsJSON, err := json.Marshal(pathsResult) 38 | if err != nil { 39 | elog.Print(err) 40 | } 41 | 42 | wsHub.broadcast <- string(pathsJSON) 43 | 44 | hostResultsRequestChan <- 1 45 | hrr := <-hostResultsResponseChan 46 | 47 | err = saveDataToDisk("hosts", hrr) 48 | if err != nil { 49 | log.Print(err) 50 | } 51 | 52 | hostsResult := HostsResult{ 53 | Hosts: hrr, 54 | MessageType: "hosts", 55 | } 56 | 57 | hostsJSON, err := json.Marshal(hostsResult) 58 | if err != nil { 59 | elog.Print(err) 60 | } 61 | 62 | wsHub.broadcast <- string(hostsJSON) 63 | 64 | } 65 | } 66 | } 67 | 68 | func saveDataToDisk(name string, data interface{}) error { 69 | 70 | name = name + resultFileNameSuffix 71 | 72 | log.Printf("Saving %s to disk", name) 73 | 74 | dataPath, err := filepath.Abs(name + ".json") 75 | if err != nil { 76 | return err 77 | } 78 | 79 | rf, err := os.OpenFile(dataPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0775) 80 | if err != nil { 81 | return err 82 | } 83 | defer rf.Close() 84 | 85 | dataJSON, err := json.Marshal(data) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | _, err = rf.Write(dataJSON) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | log.Printf("Saved %s to disk", name) 96 | 97 | return nil 98 | } 99 | 100 | func readDataFromDisk(name string, data interface{}) error { 101 | 102 | name = name + resultFileNameSuffix 103 | 104 | log.Printf("Reading %s from disk", name) 105 | 106 | dataPath, err := filepath.Abs(name + ".json") 107 | if err != nil { 108 | return err 109 | } 110 | 111 | rf, err := os.Open(dataPath) 112 | if err != nil { 113 | return err 114 | } 115 | defer rf.Close() 116 | 117 | dataJSON, err := ioutil.ReadAll(rf) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | err = json.Unmarshal(dataJSON, data) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | log.Printf("Read %s from disk", name) 128 | 129 | return nil 130 | } 131 | -------------------------------------------------------------------------------- /linux/duck: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brendanporter/duck/87b520f1a9069767767199da6a20caf64c79a94c/linux/duck -------------------------------------------------------------------------------- /mac/duck: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brendanporter/duck/87b520f1a9069767767199da6a20caf64c79a94c/mac/duck -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os" 9 | "regexp" 10 | "runtime" 11 | 12 | //"strconv" 13 | "encoding/json" 14 | "math" 15 | "net/http" 16 | "strings" 17 | "time" 18 | 19 | "github.com/brendanporter/quack" 20 | "golang.org/x/net/ipv4" 21 | ) 22 | 23 | const CLR_0 = "\x1b[30;1m" 24 | const CLR_R = "\x1b[31;1m" 25 | const CLR_G = "\x1b[32;1m" 26 | const CLR_Y = "\x1b[33;1m" 27 | const CLR_B = "\x1b[34;1m" 28 | const CLR_M = "\x1b[35;1m" 29 | const CLR_C = "\x1b[36;1m" 30 | const CLR_W = "\x1b[37;1m" 31 | const CLR_N = "\x1b[0m" 32 | 33 | var elog *log.Logger 34 | 35 | var pingResults map[string][]quack.PingResult 36 | 37 | var pingResultChan chan quack.PingResult 38 | var resultRequestChan chan int 39 | var resultResponseChan chan []byte 40 | 41 | var ttlTraceResultChan chan []quack.PingResult 42 | var unhealthyPingResultChan chan quack.PingResult 43 | 44 | var pathResultsChan chan *PathStats 45 | var pathResultsRequestChan chan int 46 | var pathResultsResponseChan chan map[string]*PathStats 47 | 48 | var hostResultsChan chan *quack.PingResult 49 | var hostResultsRequestChan chan int 50 | var hostResultsResponseChan chan map[string]*HostStats 51 | 52 | func resultProcessor() { 53 | 54 | paths := make(map[string]*PathStats) 55 | hosts := make(map[string]*HostStats) 56 | 57 | err := readDataFromDisk("pings", &pingResults) 58 | if err != nil { 59 | elog.Print(err) 60 | } 61 | 62 | err = readDataFromDisk("paths", &paths) 63 | if err != nil { 64 | elog.Print(err) 65 | } 66 | 67 | err = readDataFromDisk("hosts", &hosts) 68 | if err != nil { 69 | elog.Print(err) 70 | } 71 | 72 | go wsHub.run() 73 | 74 | http.HandleFunc("/history", historyHandler) 75 | http.HandleFunc("/ws", wsHandler) 76 | http.HandleFunc("/", baseHandler) 77 | server := http.Server{ 78 | Addr: ":14445", 79 | } 80 | 81 | go server.ListenAndServe() 82 | 83 | go saveProcessor() 84 | 85 | for { 86 | select { 87 | case pr := <-pingResultChan: 88 | 89 | if pr.ICMPMessage.Type == ipv4.ICMPTypeDestinationUnreachable { 90 | fmt.Printf("Destination network unreachable\n") 91 | continue 92 | } 93 | 94 | var color string = CLR_W 95 | if pr.Latency < 40.0 { 96 | color = CLR_G 97 | } else if pr.Latency > 65.0 && pr.Latency < 150.0 { 98 | color = CLR_Y 99 | } else if pr.Latency > 150.0 { 100 | color = CLR_R 101 | } 102 | 103 | barCount := int((pr.Latency / 2000) * 80) 104 | 105 | if runtime.GOOS != "windows" { 106 | fmt.Printf("%d bytes from %s: icmp_seq=%d ttl=%d time=%s%.3f ms |%s|%s\n", pr.Size, pr.Peer, pr.Sequence, pr.TTL, color, pr.Latency, strings.Repeat("-", barCount), CLR_W) 107 | 108 | } else { 109 | fmt.Printf("%d bytes from %s: icmp_seq=%d ttl=%d time=%.3f ms |%s|\n", pr.Size, pr.Peer, pr.Sequence, pr.TTL, pr.Latency, strings.Repeat("-", barCount)) 110 | } 111 | 112 | pingResults[pr.Target] = append(pingResults[pr.Target], pr) 113 | jsonBytes, err := json.Marshal(pr) 114 | if err != nil { 115 | elog.Print(err) 116 | } else { 117 | wsHub.broadcast <- string(jsonBytes) 118 | } 119 | 120 | case traceResult := <-ttlTraceResultChan: 121 | 122 | tr := TraceResult{ 123 | Hops: traceResult, 124 | MessageType: "hops", 125 | } 126 | jsonBytes, err := json.Marshal(tr) 127 | if err != nil { 128 | elog.Print(err) 129 | } else { 130 | wsHub.broadcast <- string(jsonBytes) 131 | } 132 | 133 | case unhealthyPingResult := <-unhealthyPingResultChan: 134 | 135 | if unhealthyPingResult.Time < 100000 { 136 | break 137 | } 138 | upr := UnhealthyPingResult{ 139 | Target: unhealthyPingResult.Target, 140 | Latency: unhealthyPingResult.Latency, 141 | Time: unhealthyPingResult.Time, 142 | MessageType: "unhealthyPingResult", 143 | } 144 | jsonBytes, err := json.Marshal(upr) 145 | if err != nil { 146 | elog.Print(err) 147 | } else { 148 | wsHub.broadcast <- string(jsonBytes) 149 | } 150 | 151 | case <-resultRequestChan: 152 | resultsJSON, err := json.Marshal(pingResults) 153 | if err != nil { 154 | elog.Print(err) 155 | } 156 | 157 | resultResponseChan <- resultsJSON 158 | 159 | case nps := <-pathResultsChan: 160 | 161 | pathName := nps.PathName 162 | 163 | if _, ok := paths[pathName]; !ok { 164 | paths[pathName] = nps 165 | } else { 166 | paths[pathName].AvgLatency = (paths[pathName].AvgLatency + nps.AvgLatency) / 2 167 | paths[pathName].MaxLatencyAvg = (paths[pathName].MaxLatencyAvg + nps.MaxLatencyAvg) / 2 168 | paths[pathName].TripCount++ 169 | } 170 | 171 | case pr := <-hostResultsChan: 172 | 173 | hostName := pr.Target 174 | 175 | if _, ok := hosts[hostName]; !ok { 176 | 177 | hostDNSNames, err := net.LookupAddr(hostName) 178 | if err != nil { 179 | elog.Print(err) 180 | } 181 | 182 | var hostDNSName string 183 | 184 | if len(hostDNSNames) > 0 { 185 | hostDNSName = hostDNSNames[0] 186 | } 187 | 188 | hosts[hostName] = &HostStats{} 189 | if pr.Latency > 0 { 190 | hosts[hostName].MinLatency = pr.Latency 191 | } else { 192 | hosts[hostName].MinLatency = 9999 193 | } 194 | hosts[hostName].AvgLatency = pr.Latency 195 | hosts[hostName].HostName = hostName 196 | hosts[hostName].HostDNSName = hostDNSName 197 | 198 | } else { 199 | 200 | if hosts[hostName].MinLatency > pr.Latency { 201 | hosts[hostName].MinLatency = pr.Latency 202 | } 203 | 204 | if hosts[hostName].MaxLatency < pr.Latency { 205 | hosts[hostName].MaxLatency = pr.Latency 206 | } 207 | 208 | hosts[hostName].AvgLatency = (hosts[hostName].AvgLatency + pr.Latency) / 2 209 | 210 | } 211 | 212 | hosts[hostName].TripCount++ 213 | 214 | if pr.Latency > 700 { 215 | hosts[hostName].HighLatency700++ 216 | } else if pr.Latency > 400 { 217 | hosts[hostName].HighLatency400++ 218 | } else if pr.Latency > 100 { 219 | hosts[hostName].HighLatency100++ 220 | } 221 | 222 | case <-pathResultsRequestChan: 223 | 224 | prr := make(map[string]*PathStats) 225 | 226 | for k, v := range paths { 227 | prr[k] = v 228 | } 229 | 230 | pathResultsResponseChan <- paths 231 | 232 | case <-hostResultsRequestChan: 233 | 234 | hrr := make(map[string]*HostStats) 235 | 236 | for k, v := range hosts { 237 | hrr[k] = v 238 | } 239 | 240 | hostResultsResponseChan <- hosts 241 | 242 | } 243 | 244 | } 245 | } 246 | 247 | var thirtySampleRollingLatency []float64 248 | 249 | func init() { 250 | pingResults = make(map[string][]quack.PingResult) 251 | pingResultChan = make(chan quack.PingResult, 10) 252 | resultResponseChan = make(chan []byte, 10) 253 | resultRequestChan = make(chan int, 10) 254 | ttlTraceResultChan = make(chan []quack.PingResult, 10) 255 | unhealthyPingResultChan = make(chan quack.PingResult, 10) 256 | 257 | pathResultsChan = make(chan *PathStats, 10) 258 | pathResultsRequestChan = make(chan int, 10) 259 | pathResultsResponseChan = make(chan map[string]*PathStats, 10) 260 | 261 | hostResultsChan = make(chan *quack.PingResult, 10) 262 | hostResultsRequestChan = make(chan int, 10) 263 | hostResultsResponseChan = make(chan map[string]*HostStats, 10) 264 | 265 | elog = log.New(os.Stdout, "Error: ", log.LstdFlags|log.Lshortfile) 266 | 267 | } 268 | 269 | func echoResults(target string, packetsTx, packetsRx int, minLatency, avgLatency, maxLatency, stdDevLatency float64) { 270 | 271 | fmt.Print("\n") 272 | fmt.Printf("--- %s ping statistics ---\n", target) 273 | fmt.Printf("%d packets transmitted, %d packets received, %.1f%% packet loss\n", packetsTx, packetsRx, (float64(packetsTx-packetsRx) / float64(packetsTx) * 100)) 274 | fmt.Printf("round-trip min/avg/max/stddev = %.3f/%.3f/%.3f/%.3f ms\n", minLatency, avgLatency, maxLatency, stdDevLatency) 275 | fmt.Printf("View charted results at: http://localhost:14445\n\n") 276 | } 277 | 278 | var resultFileNameSuffix string 279 | 280 | func main() { 281 | 282 | go resultProcessor() 283 | 284 | var maxLatency float64 285 | var minLatency float64 = 99999.9 286 | var avgLatency float64 287 | var stdDevLatency float64 288 | var packetsTx int 289 | var packetsRx int 290 | 291 | var target string 292 | 293 | //flag.StringVar(&target, "target", "8.8.8.8", "IPv4 address of target") 294 | 295 | //target := "8.8.8.8" 296 | 297 | re := regexp.MustCompile(`(!?\d{1,3})\.(!?\d{1,3})\.(!?\d{1,3})\.(!?\d{1,3})`) 298 | 299 | if len(os.Args) > 1 && re.Match([]byte(os.Args[1])) { 300 | target = os.Args[1] 301 | } 302 | 303 | flag.StringVar(&resultFileNameSuffix, "suffix", fmt.Sprintf("_%s", target), "filename suffix for storing result data (results[--].json)") 304 | 305 | flag.Parse() 306 | 307 | fmt.Printf("View charted results at: http://localhost:14445\n") 308 | 309 | pingTicker := time.NewTicker(time.Second) 310 | traceTicker := time.NewTicker(time.Second * 30) 311 | for { 312 | select { 313 | case <-pingTicker.C: 314 | packetsTx++ 315 | latency, err := quack.SendPing(target, 0, pingResultChan) 316 | if err != nil { 317 | log.Print(err) 318 | fmt.Printf("Request timeout for icmp_seq %d\n", packetsTx) 319 | latency = 2000.0 320 | } else { 321 | packetsRx++ 322 | } 323 | 324 | if avgLatency == 0 { 325 | avgLatency = latency 326 | } else { 327 | avgLatency = (avgLatency + latency) / 2 328 | } 329 | 330 | stdDevLatency += math.Pow(latency-avgLatency, 2) 331 | stdDevLatency = math.Sqrt(stdDevLatency / 2) 332 | 333 | if latency < minLatency { 334 | minLatency = latency 335 | } 336 | 337 | if latency > maxLatency { 338 | maxLatency = latency 339 | } 340 | 341 | if packetsTx%30 == 0 { 342 | echoResults(target, packetsTx, packetsRx, minLatency, avgLatency, maxLatency, stdDevLatency) 343 | } 344 | 345 | //time.Sleep(time.Duration(time.Second.Nanoseconds() - int64(latency*1000000))) 346 | 347 | if len(thirtySampleRollingLatency) == 30 { 348 | thirtySampleRollingLatency = thirtySampleRollingLatency[1:] 349 | } 350 | thirtySampleRollingLatency = append(thirtySampleRollingLatency, latency) 351 | 352 | case <-traceTicker.C: 353 | go func() { 354 | traceResults, err := quack.TTLTrace(target) 355 | if err != nil { 356 | elog.Print(err) 357 | } 358 | 359 | digestTraceResults(traceResults) 360 | 361 | ttlTraceResultChan <- traceResults 362 | 363 | var lastLatency float64 364 | var highLatencyHosts []quack.PingResult 365 | var latencyHighWaterMark float64 366 | 367 | for i, traceResult := range traceResults { 368 | 369 | //log.Printf("TTL %d result: %#v", i+1, traceResult) 370 | 371 | if lastLatency == 0 { 372 | lastLatency = traceResult.Latency 373 | continue 374 | } 375 | 376 | if math.Abs(traceResult.Latency-lastLatency) > 100 && traceResult.Latency != 0 && traceResult.Latency > latencyHighWaterMark { 377 | if i > 0 && traceResults[i-1].Latency > 100 { 378 | highLatencyHosts = append(highLatencyHosts, traceResults[i-1]) 379 | } 380 | highLatencyHosts = append(highLatencyHosts, traceResult) 381 | } 382 | lastLatency = traceResult.Latency 383 | 384 | if traceResult.Latency > latencyHighWaterMark { 385 | latencyHighWaterMark = traceResult.Latency 386 | } 387 | 388 | } 389 | 390 | // If latency was high, perform additional ttlTraces to collect more path observations 391 | 392 | if latencyHighWaterMark > 100 { 393 | log.Printf("High latency of %.1fms detected. Performing additional traces.", latencyHighWaterMark) 394 | for x := 0; x < 4; x++ { 395 | traceResults, err := quack.TTLTrace(target) 396 | if err != nil { 397 | elog.Print(err) 398 | } 399 | 400 | _ = traceResults 401 | 402 | time.Sleep(time.Millisecond * 200) 403 | } 404 | } 405 | 406 | for _, highLatencyHost := range highLatencyHosts { 407 | log.Printf("Found potentially unhealthy host in trace: %#v", highLatencyHost) 408 | 409 | unhealthyPingResultChan <- highLatencyHost 410 | } 411 | 412 | }() 413 | } 414 | } 415 | 416 | } 417 | 418 | func digestTraceResults(traceResults []quack.PingResult) { 419 | 420 | var pathName string 421 | var targetNames []string 422 | var maxPathLatency float64 423 | var avgPathLatency float64 424 | 425 | for _, traceresult := range traceResults { 426 | 427 | targetNames = append(targetNames, traceresult.Target) 428 | 429 | tr := traceresult 430 | 431 | hostResultsChan <- &tr 432 | 433 | if avgPathLatency == 0 { 434 | avgPathLatency = traceresult.Latency 435 | } else { 436 | avgPathLatency = (avgPathLatency + traceresult.Latency) / 2 437 | } 438 | if maxPathLatency < traceresult.Latency { 439 | maxPathLatency = traceresult.Latency 440 | } 441 | 442 | } 443 | 444 | pathName = strings.Join(targetNames, "-") 445 | 446 | newPathStats := &PathStats{ 447 | PathName: pathName, 448 | AvgLatency: avgPathLatency, 449 | MaxLatencyAvg: maxPathLatency, 450 | TripCount: 1, 451 | } 452 | 453 | pathResultsChan <- newPathStats 454 | 455 | } 456 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Duck long-term network latency tester 2 | 3 | How to use 4 | --- 5 | 6 | - Find your binary and run from the command line as administrator/root (for ICMP listener) 7 | - Watch the pings roll in 8 | - Browse to http://localhost:14445 and watch the results 9 | - Build from source with `go build` 10 | - Mac/Linux 11 | - Run duck with `sudo ./duck ` 12 | - Windows 13 | - Run duck from administrator command prompt with `duck.exe ` 14 | 15 | Screenshots 16 | --- 17 | 18 | ![Image of Duck in Mac Terminal](https://brendanporter.github.io/duck-terminalui_v3.png) 19 | ![image of Duck in Browser](https://brendanporter.github.io/duck-webui.png) 20 | 21 | Goals 22 | --- 23 | 24 | - Be a better, in-place replacement for terminal `ping` command 25 | - Capture high latency events in the background and make them clear on review 26 | 27 | 28 | Features 29 | --- 30 | - In-terminal ASCII latency charting 31 | - Colorization of latency in terminal (Mac and Linux only) 32 | - Statistics printed every 30 seconds 33 | - Live web dashboard for visualizing latency over extended tests 34 | - Long-term chart with 30-second averaged samples 35 | - Short-term chart (last 10 minutes) with live results 36 | - Traceroute results 37 | - Traceroute Path tracking with latency averaging 38 | - Individual traceroute host tracking with latency averaging and incident counting 39 | - Keeps history of ping performance older than 10 minutes as derezzed 30-second sample averages 40 | - Traceroute performed every 30 seconds 41 | - Paths stored and path traversal tracked 42 | - Host DNS lookup 43 | - Average and Max latency tracked over time 44 | - Stores pings, paths, and hosts to JSON files every minute 45 | 46 | Roadmap 47 | --- 48 | - Derezzing of ping results after 10 minutes 49 | - Exception to derezzeing is any high-latency event, with 5 minutes of high resolution context on either side 50 | - Variable control from WebUI 51 | - Payload size 52 | - Latency threshold adjustments 53 | - Jitter visualization -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/brendanporter/quack" 4 | 5 | type UnhealthyPingResult struct { 6 | Target string 7 | Latency float64 8 | Time int64 9 | MessageType string `json:"mt"` 10 | } 11 | 12 | type TraceResult struct { 13 | Hops []quack.PingResult 14 | MessageType string `json:"mt"` 15 | } 16 | 17 | type PathsResult struct { 18 | Paths map[string]*PathStats 19 | MessageType string `json:"mt"` 20 | } 21 | 22 | type HostsResult struct { 23 | Hosts map[string]*HostStats 24 | MessageType string `json:"mt"` 25 | } 26 | 27 | type HostStats struct { 28 | HostName string 29 | HostDNSName string 30 | AvgLatency float64 31 | MaxLatency float64 32 | MinLatency float64 33 | TripCount int 34 | HighLatency100 int 35 | HighLatency400 int 36 | HighLatency700 int 37 | } 38 | 39 | type PathStats struct { 40 | PathName string 41 | MaxLatencyAvg float64 42 | AvgLatency float64 43 | TripCount int 44 | } 45 | -------------------------------------------------------------------------------- /windows/duck.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brendanporter/duck/87b520f1a9069767767199da6a20caf64c79a94c/windows/duck.exe --------------------------------------------------------------------------------