diff --git a/Dockerfile b/Dockerfile index 03ceb45..53ab1f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,13 @@ WORKDIR /app/src COPY src/ . -RUN go mod init ngtech.co.il/network/livtrace && go mod tidy +RUN go mod init ngtech.co.il/network/livtrace/v2 && go mod tidy RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -ldflags="-s -w" -o /out/myapp-linux-amd64 . + go build -ldflags="-s -w" -o /out/livetrace-linux-amd64 . RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ - go build -ldflags="-s -w" -o /out/myapp-windows-amd64.exe . + go build -ldflags="-s -w" -o /out/livetrace-windows-amd64.exe . # Stage 2: Export target FROM scratch AS bin diff --git a/dist/livetrace-linux-amd64 b/dist/livetrace-linux-amd64 index 85b1804..cdc11bf 100755 Binary files a/dist/livetrace-linux-amd64 and b/dist/livetrace-linux-amd64 differ diff --git a/dist/livetrace-windows-amd64.exe b/dist/livetrace-windows-amd64.exe index ea64da3..44482be 100755 Binary files a/dist/livetrace-windows-amd64.exe and b/dist/livetrace-windows-amd64.exe differ diff --git a/src/main.go b/src/main.go index f352e6a..2f57829 100644 --- a/src/main.go +++ b/src/main.go @@ -1,11 +1,11 @@ // livetrace - continuous traceroute with live TUI -// Inspired by MikroTik RouterOS traceroute tool. -// Requires root / Administrator (raw ICMP sockets). +// Inspired by MikroTik RouterOS traceroute. +// Requires root / Administrator (raw ICMP socket for receiving). // -// Usage: livetrace [-m maxhops] [-i interval_ms] [-t timeout_ms] +// Usage: livetrace [options] // Build: go build -o livetrace . -// Linux: sudo ./livetrace 8.8.8.8 -// Windows (as Administrator): livetrace.exe 8.8.8.8 +// Linux: sudo ./livetrace 8.8.8.8 +// Windows: run as Administrator: livetrace.exe 8.8.8.8 // // Recommended terminal width: 130+ columns. @@ -31,45 +31,41 @@ import ( ) const ( - icmpProto = 1 - probeData = "livetrace" - histSize = 25 // number of probe results kept per hop + icmpProto = 1 + histSize = 25 // history slots per hop + minPktSize = 28 // IP(20) + ICMP/UDP header(8) — zero payload + defPktSize = 56 // matches common ping/traceroute default ) -// barChars maps a normalized level (0–7) to a Unicode block character. var barChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} // ─── History ring buffer ────────────────────────────────────────────────────── -// HistEntry is one slot in the per-hop history ring buffer. -// timedOut=true means no reply was received for that probe. type HistEntry struct { timedOut bool - rtt float64 // ms; meaningful only when timedOut=false + rtt float64 // ms; valid when timedOut=false } // ─── Per-hop statistics ─────────────────────────────────────────────────────── -// Hop tracks all state for one TTL level. type Hop struct { mu sync.Mutex ttl int - addrs map[string]bool // all IPs that have responded (ECMP support) - host string // reverse-DNS of the first addr seen + noDNS bool + addrs map[string]bool // all IPs that responded (ECMP support) + host string // reverse-DNS of first addr sent int recv int - last float64 // RTT of the most recent reply (ms) + last float64 lastTimeout bool // true if the most recent resolved probe timed out sumRTT float64 - sumRTTSq float64 // Σ(rtt²) — used to compute std deviation online + sumRTTSq float64 // Σ(rtt²) for online std-dev computation best float64 worst float64 - reached bool // true if this hop is the destination - - // Ring buffer: oldest entry is at history[histPos] when histLen==histSize. - history [histSize]HistEntry - histLen int // entries filled so far (0 → histSize) - histPos int // index of the next write slot + reached bool + history [histSize]HistEntry + histLen int + histPos int // next write slot (ring buffer) } func (h *Hop) recordSent() { @@ -95,15 +91,14 @@ func (h *Hop) recordRecv(rtt float64, from string, reached bool) { } h.reached = reached - // Track all responding IPs (ECMP load-balanced paths show multiple). if h.addrs == nil { h.addrs = make(map[string]bool) } isNew := !h.addrs[from] h.addrs[from] = true - // Kick off async reverse-DNS only for the very first IP we see. - if isNew && len(h.addrs) == 1 { + // Async reverse-DNS only for the first IP ever seen, and only if enabled. + if isNew && len(h.addrs) == 1 && !h.noDNS { go func(ip string) { names, err := net.LookupAddr(ip) h.mu.Lock() @@ -117,7 +112,6 @@ func (h *Hop) recordRecv(rtt float64, from string, reached bool) { h.pushHistory(HistEntry{rtt: rtt}) } -// recordTimeout is called when a probe expires without a reply. func (h *Hop) recordTimeout() { h.mu.Lock() defer h.mu.Unlock() @@ -133,10 +127,10 @@ func (h *Hop) pushHistory(e HistEntry) { } } -// HopSnap is a point-in-time snapshot of a Hop used for rendering. +// HopSnap is a render-time snapshot of a hop. type HopSnap struct { TTL int - Addrs []string // sorted list of all responding IPs + Addrs []string // sorted; multiple = ECMP Host string Sent int Recv int @@ -148,7 +142,7 @@ type HopSnap struct { StdDev float64 Loss float64 Reached bool - History []HistEntry // chronological slice (oldest → newest) + History []HistEntry // oldest → newest } func (h *Hop) snapshot() HopSnap { @@ -166,7 +160,6 @@ func (h *Hop) snapshot() HopSnap { Worst: h.worst, Reached: h.reached, } - for addr := range h.addrs { s.Addrs = append(s.Addrs, addr) } @@ -174,63 +167,84 @@ func (h *Hop) snapshot() HopSnap { if h.recv > 0 { s.Avg = h.sumRTT / float64(h.recv) - // Population variance: E[x²] − E[x]² - variance := h.sumRTTSq/float64(h.recv) - s.Avg*s.Avg - if variance > 0 { - s.StdDev = math.Sqrt(variance) + if v := h.sumRTTSq/float64(h.recv) - s.Avg*s.Avg; v > 0 { + s.StdDev = math.Sqrt(v) } } if h.sent > 0 { s.Loss = float64(h.sent-h.recv) / float64(h.sent) * 100 } - // Copy history in chronological order (oldest → newest). if h.histLen > 0 { s.History = make([]HistEntry, h.histLen) if h.histLen < histSize { copy(s.History, h.history[:h.histLen]) } else { - // Buffer is full; oldest entry is at h.histPos. n := copy(s.History, h.history[h.histPos:]) copy(s.History[n:], h.history[:h.histPos]) } } - return s } -// ─── Pending probe tracking ─────────────────────────────────────────────────── +// ─── Pending probe ──────────────────────────────────────────────────────────── type pending struct { ttl int sent time.Time + port int // UDP destination port (UDP mode); 0 for ICMP mode } // ─── Tracer ─────────────────────────────────────────────────────────────────── type Tracer struct { - target string - targetIP net.IP - maxHops int - interval time.Duration - timeout time.Duration - hops []*Hop + // config + target string + targetIP net.IP + maxHops int + interval time.Duration + timeout time.Duration + proto string // "icmp" or "udp" + basePort int // UDP: starting destination port + pktSize int // total IP packet size in bytes + maxRounds int // 0 = unlimited + maxDur time.Duration // 0 = unlimited - conn *icmp.PacketConn - pc *ipv4.PacketConn - pid int + hops []*Hop + pid int // used as ICMP Echo identifier - mu sync.Mutex - seqCtr int - probes map[int]*pending // ICMP seq → pending probe - maxActive int // don't probe past the destination - sendMu sync.Mutex // serialise SetTTL + WriteTo + // sockets + // icmpConn is always opened (for receiving ICMP errors and, in ICMP mode, + // for sending Echo Requests). + icmpConn *icmp.PacketConn + icmpPC *ipv4.PacketConn + // udpSend is only opened in UDP mode; it sends the UDP probes. + udpSend net.PacketConn + udpPC *ipv4.PacketConn + // probe tracking + mu sync.Mutex + seqCtr int + probes map[int]*pending // key: ICMP seq (ICMP) or sequential int (UDP) + portToKey map[int]int // UDP only: dstPort → probe key + maxActive int + sendMu sync.Mutex // serialises SetTTL + send + + // lifecycle stopCh chan struct{} started time.Time } -func NewTracer(target string, maxHops int, interval, timeout time.Duration) (*Tracer, error) { +func NewTracer( + target string, + maxHops int, + interval, timeout time.Duration, + proto string, + basePort, pktSize int, + maxRounds int, + maxDur time.Duration, + noDNS bool, +) (*Tracer, error) { ips, err := net.LookupHost(target) if err != nil { return nil, fmt.Errorf("resolve %q: %w", target, err) @@ -246,9 +260,13 @@ func NewTracer(target string, maxHops int, interval, timeout time.Duration) (*Tr return nil, fmt.Errorf("no IPv4 address for %s (IPv6 not yet supported)", target) } + if pktSize < minPktSize { + pktSize = minPktSize + } + hops := make([]*Hop, maxHops) for i := range hops { - hops[i] = &Hop{ttl: i + 1} + hops[i] = &Hop{ttl: i + 1, noDNS: noDNS} } return &Tracer{ @@ -257,22 +275,33 @@ func NewTracer(target string, maxHops int, interval, timeout time.Duration) (*Tr maxHops: maxHops, interval: interval, timeout: timeout, + proto: proto, + basePort: basePort, + pktSize: pktSize, + maxRounds: maxRounds, + maxDur: maxDur, hops: hops, pid: os.Getpid() & 0xffff, probes: make(map[int]*pending), + portToKey: make(map[int]int), maxActive: maxHops, stopCh: make(chan struct{}), started: time.Now(), }, nil } +// Done returns a channel that is closed when the tracer stops (either due to +// count/duration limits or an explicit Stop() call). +func (t *Tracer) Done() <-chan struct{} { return t.stopCh } + func (t *Tracer) Start() error { var err error - // Try privileged raw socket first. - t.conn, err = icmp.ListenPacket("ip4:icmp", "0.0.0.0") + + // ICMP socket — always required (receiving ICMP errors, and sending in ICMP mode). + t.icmpConn, err = icmp.ListenPacket("ip4:icmp", "0.0.0.0") if err != nil { - // Fallback: unprivileged ICMP dgram (Linux ≥5.x with ping_group_range set). - t.conn, err = icmp.ListenPacket("udp4", "0.0.0.0:0") + // Fallback: unprivileged ICMP dgram (Linux ≥5.x with ping_group_range). + t.icmpConn, err = icmp.ListenPacket("udp4", "0.0.0.0:0") if err != nil { hint := "try: sudo ./livetrace" if runtime.GOOS == "windows" { @@ -281,7 +310,17 @@ func (t *Tracer) Start() error { return fmt.Errorf("cannot open ICMP socket (%s): %w", hint, err) } } - t.pc = t.conn.IPv4PacketConn() + t.icmpPC = t.icmpConn.IPv4PacketConn() + + // UDP sending socket — only needed in UDP mode. + if t.proto == "udp" { + t.udpSend, err = net.ListenPacket("udp4", ":0") + if err != nil { + _ = t.icmpConn.Close() + return fmt.Errorf("cannot open UDP send socket: %w", err) + } + t.udpPC = ipv4.NewPacketConn(t.udpSend) + } go t.receiveLoop() go t.probeLoop() @@ -295,7 +334,10 @@ func (t *Tracer) Stop() { default: close(t.stopCh) } - _ = t.conn.Close() + _ = t.icmpConn.Close() + if t.udpSend != nil { + _ = t.udpSend.Close() + } } func (t *Tracer) nextSeq() int { @@ -305,17 +347,55 @@ func (t *Tracer) nextSeq() int { return t.seqCtr } -// sendProbe sends one ICMP Echo Request with the given TTL. -// SetTTL + WriteTo are serialised with sendMu to avoid a socket-option race. -func (t *Tracer) sendProbe(ttl int) { - seq := t.nextSeq() +// udpPortForKey maps a sequential probe key to a UDP destination port. +func (t *Tracer) udpPortForKey(key int) int { + spread := 65535 - t.basePort + if spread < 1 { + spread = 1 + } + return t.basePort + (key-1)%spread +} +// payloadSize returns the number of bytes to put in the ICMP/UDP data section. +func (t *Tracer) payloadSize() int { + // pktSize = IP header(20) + protocol header(8) + payload + sz := t.pktSize - 28 + if sz < 0 { + sz = 0 + } + return sz +} + +// makeICMPBytes builds a marshalled ICMP Echo Request. +func (t *Tracer) makeICMPBytes(seq int) ([]byte, error) { + data := make([]byte, t.payloadSize()) + copy(data, "livetrace") // tag for identification msg := icmp.Message{ Type: ipv4.ICMPTypeEcho, Code: 0, - Body: &icmp.Echo{ID: t.pid, Seq: seq, Data: []byte(probeData)}, + Body: &icmp.Echo{ID: t.pid, Seq: seq, Data: data}, } - b, err := msg.Marshal(nil) + return msg.Marshal(nil) +} + +// makeUDPBytes builds a raw UDP payload of the configured size. +func (t *Tracer) makeUDPBytes() []byte { + return make([]byte, t.payloadSize()) +} + +// ─── Sending ────────────────────────────────────────────────────────────────── + +func (t *Tracer) sendProbe(ttl int) { + if t.proto == "udp" { + t.sendProbeUDP(ttl) + } else { + t.sendProbeICMP(ttl) + } +} + +func (t *Tracer) sendProbeICMP(ttl int) { + seq := t.nextSeq() + b, err := t.makeICMPBytes(seq) if err != nil { return } @@ -323,12 +403,11 @@ func (t *Tracer) sendProbe(ttl int) { t.mu.Lock() t.probes[seq] = &pending{ttl: ttl, sent: time.Now()} t.mu.Unlock() - t.hops[ttl-1].recordSent() t.sendMu.Lock() - _ = t.pc.SetTTL(ttl) - _, err = t.conn.WriteTo(b, &net.IPAddr{IP: t.targetIP}) + _ = t.icmpPC.SetTTL(ttl) + _, err = t.icmpConn.WriteTo(b, &net.IPAddr{IP: t.targetIP}) t.sendMu.Unlock() if err != nil { @@ -342,8 +421,49 @@ func (t *Tracer) sendProbe(ttl int) { } } +func (t *Tracer) sendProbeUDP(ttl int) { + key := t.nextSeq() + dstPort := t.udpPortForKey(key) + payload := t.makeUDPBytes() + + t.mu.Lock() + t.probes[key] = &pending{ttl: ttl, sent: time.Now(), port: dstPort} + t.portToKey[dstPort] = key + t.mu.Unlock() + t.hops[ttl-1].recordSent() + + t.sendMu.Lock() + _ = t.udpPC.SetTTL(ttl) + _, err := t.udpSend.WriteTo(payload, &net.UDPAddr{IP: t.targetIP, Port: dstPort}) + t.sendMu.Unlock() + + if err != nil { + t.mu.Lock() + delete(t.probes, key) + delete(t.portToKey, dstPort) + t.mu.Unlock() + h := t.hops[ttl-1] + h.mu.Lock() + h.sent-- + h.mu.Unlock() + } +} + +// ─── Probe loop ─────────────────────────────────────────────────────────────── + func (t *Tracer) probeLoop() { + round := 0 for { + // Check stop conditions. + if t.maxRounds > 0 && round >= t.maxRounds { + t.Stop() + return + } + if t.maxDur > 0 && time.Since(t.started) >= t.maxDur { + t.Stop() + return + } + t.mu.Lock() maxActive := t.maxActive t.mu.Unlock() @@ -357,6 +477,7 @@ func (t *Tracer) probeLoop() { t.sendProbe(ttl) time.Sleep(3 * time.Millisecond) } + round++ select { case <-t.stopCh: @@ -366,6 +487,8 @@ func (t *Tracer) probeLoop() { } } +// ─── Receive loop ───────────────────────────────────────────────────────────── + func (t *Tracer) receiveLoop() { buf := make([]byte, 1500) for { @@ -375,8 +498,8 @@ func (t *Tracer) receiveLoop() { default: } - _ = t.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) - n, peer, err := t.conn.ReadFrom(buf) + _ = t.icmpConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, peer, err := t.icmpConn.ReadFrom(buf) if err != nil { if ne, ok := err.(net.Error); ok && ne.Timeout() { continue @@ -400,6 +523,10 @@ func (t *Tracer) receiveLoop() { switch msg.Type { case ipv4.ICMPTypeEchoReply: + // Only relevant in ICMP mode. + if t.proto != "icmp" { + continue + } reply, ok := msg.Body.(*icmp.Echo) if !ok || reply.ID != t.pid { continue @@ -411,52 +538,109 @@ func (t *Tracer) receiveLoop() { if !ok { continue } - origSeq, origID, ok := extractOriginalICMP(te.Data) - if !ok || origID != t.pid { - continue + if t.proto == "udp" { + dstPort, ok := extractOriginalUDPPort(te.Data) + if !ok { + continue + } + t.mu.Lock() + key, exists := t.portToKey[dstPort] + t.mu.Unlock() + if !exists { + continue + } + t.handleReply(key, from, now, false) + } else { + seq, id, ok := extractOriginalICMP(te.Data) + if !ok || id != t.pid { + continue + } + t.handleReply(seq, from, now, false) } - t.handleReply(origSeq, from, now, false) case ipv4.ICMPTypeDestinationUnreachable: - // In UDP mode, port-unreachable means we reached the destination. + // In UDP mode, Port Unreachable means we reached the destination. + // In ICMP mode (unprivileged udp4 socket fallback), treat as destination. du, ok := msg.Body.(*icmp.DstUnreach) if !ok { continue } - origSeq, origID, ok := extractOriginalICMP(du.Data) - if !ok || origID != t.pid { - continue + if t.proto == "udp" { + dstPort, ok := extractOriginalUDPPort(du.Data) + if !ok { + continue + } + t.mu.Lock() + key, exists := t.portToKey[dstPort] + t.mu.Unlock() + if !exists { + continue + } + t.handleReply(key, from, now, true) + } else { + seq, id, ok := extractOriginalICMP(du.Data) + if !ok || id != t.pid { + continue + } + t.handleReply(seq, from, now, true) } - t.handleReply(origSeq, from, now, true) } } } -// extractOriginalICMP pulls the ICMP id+seq from the original IP+ICMP header -// embedded in ICMP error messages (Time Exceeded, Dest Unreachable). +// ─── ICMP error payload extraction ─────────────────────────────────────────── + +// extractOriginalICMP reads the ICMP id and seq from the original IP+ICMP +// header embedded in a Time Exceeded or Dest Unreachable reply. func extractOriginalICMP(data []byte) (seq, id int, ok bool) { if len(data) < 28 { return 0, 0, false } - ihl := int(data[0]&0x0f) * 4 // IPv4 header length + ihl := int(data[0]&0x0f) * 4 if ihl < 20 || len(data) < ihl+8 { return 0, 0, false } + if data[9] != 1 { // protocol must be ICMP (1) + return 0, 0, false + } orig := data[ihl : ihl+8] - // ICMP header layout: type(1) code(1) checksum(2) id(2) seq(2) + // ICMP: type(1) code(1) checksum(2) id(2) seq(2) id = int(binary.BigEndian.Uint16(orig[4:6])) seq = int(binary.BigEndian.Uint16(orig[6:8])) return seq, id, true } -func (t *Tracer) handleReply(seq int, from string, now time.Time, reached bool) { +// extractOriginalUDPPort reads the original UDP destination port from the +// embedded original IP+UDP header in a Time Exceeded or Dest Unreachable reply. +func extractOriginalUDPPort(data []byte) (dstPort int, ok bool) { + if len(data) < 28 { + return 0, false + } + ihl := int(data[0]&0x0f) * 4 + if ihl < 20 || len(data) < ihl+8 { + return 0, false + } + if data[9] != 17 { // protocol must be UDP (17) + return 0, false + } + // UDP: srcPort(2) dstPort(2) length(2) checksum(2) + dstPort = int(binary.BigEndian.Uint16(data[ihl+2 : ihl+4])) + return dstPort, true +} + +// ─── Reply handler ──────────────────────────────────────────────────────────── + +func (t *Tracer) handleReply(key int, from string, now time.Time, reached bool) { t.mu.Lock() - p, ok := t.probes[seq] + p, ok := t.probes[key] if !ok { t.mu.Unlock() return } - delete(t.probes, seq) + delete(t.probes, key) + if p.port != 0 { + delete(t.portToKey, p.port) + } rtt := now.Sub(p.sent).Seconds() * 1000 ttl := p.ttl t.mu.Unlock() @@ -475,8 +659,14 @@ func (t *Tracer) handleReply(seq int, from string, now time.Time, reached bool) } } -// cleanupLoop expires probes that never received a reply and records them -// as timeouts in the hop's history ring buffer. +// ─── Cleanup loop ───────────────────────────────────────────────────────────── + +// expiredProbe holds just enough info to call recordTimeout after releasing mu. +type expiredProbe struct { + ttl int + port int // UDP port, or 0 for ICMP +} + func (t *Tracer) cleanupLoop() { tick := time.NewTicker(250 * time.Millisecond) defer tick.Stop() @@ -485,27 +675,30 @@ func (t *Tracer) cleanupLoop() { case <-t.stopCh: return case now := <-tick.C: - var expired []int // TTLs whose probes timed out + var expired []expiredProbe t.mu.Lock() - for seq, p := range t.probes { + for key, p := range t.probes { if now.Sub(p.sent) > t.timeout { - expired = append(expired, p.ttl) - delete(t.probes, seq) + expired = append(expired, expiredProbe{ttl: p.ttl, port: p.port}) + if p.port != 0 { + delete(t.portToKey, p.port) + } + delete(t.probes, key) } } t.mu.Unlock() - // Record timeouts outside the main lock. - for _, ttl := range expired { - if ttl >= 1 && ttl <= t.maxHops { - t.hops[ttl-1].recordTimeout() + // Record timeouts outside the lock so recordTimeout can acquire h.mu freely. + for _, e := range expired { + if e.ttl >= 1 && e.ttl <= t.maxHops { + t.hops[e.ttl-1].recordTimeout() } } } } } -// ─── ANSI colour constants ──────────────────────────────────────────────────── +// ─── TUI display ───────────────────────────────────────────────────────────── const ( colReset = "\033[0m" @@ -517,35 +710,20 @@ const ( colDim = "\033[2m" ) -// ─── History bar rendering ──────────────────────────────────────────────────── - -// renderHistory returns a histSize-wide string of Unicode block chars with -// ANSI colour codes. -// -// Colour rules: -// - timeout → red █ (cyan █ if it is the most recent entry) -// - rtt > 3× best → yellow bar (elevated latency) -// - otherwise → green bar -// - most recent → cyan (overrides the above) -// -// Bar height is normalised per-hop (best→worst maps to ▁→█). -// Unfilled slots on the left are spaces (bars grow right as data arrives). func renderHistory(hist []HistEntry, best, worst float64) string { var sb strings.Builder - // Left-pad with spaces until the buffer fills up. for i := 0; i < histSize-len(hist); i++ { sb.WriteByte(' ') } rng := worst - best if rng < 1 { - rng = 1 // avoid divide-by-zero; all bars will be at minimum height + rng = 1 } for i, e := range hist { isLatest := i == len(hist)-1 - var color, char string if e.timedOut { @@ -556,11 +734,10 @@ func renderHistory(hist []HistEntry, best, worst float64) string { color = colRed } } else { - // Map RTT onto 0–7. level := 0 if best > 0 && worst > best { norm := (e.rtt - best) / rng - level = int(norm * 8) // 0.0→0, 1.0→8 (clamped below) + level = int(norm * 8) if level < 0 { level = 0 } @@ -569,7 +746,6 @@ func renderHistory(hist []HistEntry, best, worst float64) string { } } char = string(barChars[level]) - switch { case isLatest: color = colCyan @@ -579,46 +755,33 @@ func renderHistory(hist []HistEntry, best, worst float64) string { color = colGreen } } - sb.WriteString(color + char + colReset) } - return sb.String() } -// ─── TUI rendering ──────────────────────────────────────────────────────────── - -// formatHost builds the label for the Host column. -// Shows hostname if resolved, primary IP otherwise. -// If ECMP is detected (multiple IPs), appends extra IPs. func formatHost(s HopSnap, maxLen int) string { if len(s.Addrs) == 0 { return "???" } - primary := s.Addrs[0] if s.Host != "" { primary = s.Host } - if len(s.Addrs) == 1 { if len(primary) > maxLen { return primary[:maxLen-3] + "..." } return primary } - - // ECMP: show all IPs comma-separated after the primary label. - extras := strings.Join(s.Addrs[1:], ", ") - full := primary + ", " + extras + // ECMP: comma-separate all IPs after the primary label. + full := primary + ", " + strings.Join(s.Addrs[1:], ", ") if len(full) > maxLen { return full[:maxLen-3] + "..." } return full } -// fms formats an RTT value as a right-aligned 7-character string. -// Returns " -" (7 chars) for zero / no data. func fms(v float64) string { if v == 0 { return " -" @@ -626,7 +789,6 @@ func fms(v float64) string { return fmt.Sprintf("%7.1f", v) } -// fdev formats a standard deviation value as a 6-character string. func fdev(v float64) string { if v == 0 { return " -" @@ -640,30 +802,24 @@ func (t *Tracer) Render() { t.mu.Unlock() elapsed := time.Since(t.started) - - // Reposition cursor to top-left (avoids full-screen flicker). fmt.Print("\033[H") - // ── Header ────────────────────────────────────────────────────────────── + // Header line label := t.target if label != t.targetIP.String() { label = fmt.Sprintf("%s (%s)", t.target, t.targetIP) } - fmt.Printf("%sLiveTrace%s → %-44s %s %s[q] quit%s\n\n", - colBold, colReset, label, formatElapsed(elapsed), colDim, colReset, + protoTag := fmt.Sprintf("[%s/%dB]", strings.ToUpper(t.proto), t.pktSize) + fmt.Printf("%sLiveTrace%s → %-36s %s %s %s[Ctrl+C to quit]%s\n\n", + colBold, colReset, label, protoTag, formatElapsed(elapsed), colDim, colReset, ) - // ── Column headers ─────────────────────────────────────────────────────── - // - // Layout (130 chars): - // 2 + 4 + 2 + 28 + 2 + 6 + 2 + 4 + 2 + 4 + - // 2 + 7 + 2 + 7 + 2 + 7 + 2 + 7 + 2 + 6 + 2 + 25 - // = 131 chars + // Column headers fmt.Printf(" %s%-4s %-28s %6s %4s %4s %7s %7s %7s %7s %6s %-25s%s\n", colBold, "Hop", "Host", "Loss%", "Snt", "Rcv", "Last", "Avg", "Best", "Wrst", "StdD", - fmt.Sprintf("History (%ds window)", histSize), + fmt.Sprintf("History (%ds)", histSize), colReset, ) fmt.Println(" " + strings.Repeat("─", 129)) @@ -671,14 +827,12 @@ func (t *Tracer) Render() { for i := 0; i < maxActive && i < len(t.hops); i++ { renderHopRow(t.hops[i].snapshot()) } - fmt.Println() } func renderHopRow(s HopSnap) { label := formatHost(s, 28) - // Loss column colour. lc, rc := "", "" switch { case s.Loss >= 50: @@ -689,22 +843,18 @@ func renderHopRow(s HopSnap) { lc, rc = colGreen, colReset } - // Last RTT column: show "timeout" when the most recent probe timed out, - // the RTT value when it responded, or "-" when no data yet. var lastStr string switch { case s.LastTimeout: - lastStr = "timeout" // 7 chars — matches fms width + lastStr = "timeout" case s.Last > 0: lastStr = fmt.Sprintf("%7.1f", s.Last) default: lastStr = " -" } - // History bar chart (pre-coloured string, histSize wide). histStr := renderHistory(s.History, s.Best, s.Worst) - // Destination marker. reachedStr := "" if s.Reached { reachedStr = colGreen + " ✓" + colReset @@ -714,18 +864,13 @@ func renderHopRow(s HopSnap) { s.TTL, label, lc, s.Loss, rc, s.Sent, s.Recv, - lastStr, - fms(s.Avg), - fms(s.Best), - fms(s.Worst), + lastStr, fms(s.Avg), fms(s.Best), fms(s.Worst), fdev(s.StdDev), histStr, reachedStr, ) } -// ─── Utilities ──────────────────────────────────────────────────────────────── - func formatElapsed(d time.Duration) string { h := int(d.Hours()) m := int(d.Minutes()) % 60 @@ -750,14 +895,32 @@ func peerString(addr net.Addr) string { // ─── Entry point ───────────────────────────────────────────────────────────── func main() { - maxHops := flag.Int("m", 30, "maximum hops") + maxHops := flag.Int("m", 30, "maximum hops") intervalMS := flag.Int("i", 1000, "probe interval in milliseconds") - timeoutMS := flag.Int("t", 3000, "probe timeout in milliseconds") + timeoutMS := flag.Int("t", 3000, "probe timeout in milliseconds") + proto := flag.String("proto", "icmp", "probe protocol: icmp or udp") + port := flag.Int("port", 33434, "base destination port (UDP mode only)") + size := flag.Int("size", defPktSize, "total IP packet size in bytes (min 28); use 1500 for MTU test") + noDNS := flag.Bool("n", false, "disable reverse DNS (show numeric IPs only)") + count := flag.Int("count", 0, "stop after N complete probe rounds (0 = run forever)") + dur := flag.Duration("duration", 0, "stop after this duration, e.g. 30s, 5m (0 = run forever)") + flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: livetrace [options] \n\nOptions:\n") + fmt.Fprintf(os.Stderr, `Usage: livetrace [options] + +Options: +`) flag.PrintDefaults() - fmt.Fprintf(os.Stderr, "\nRequires root (Linux/macOS) or Administrator (Windows).\n") - fmt.Fprintf(os.Stderr, "Recommended terminal width: 130+ columns.\n") + fmt.Fprintf(os.Stderr, ` +Requires root (Linux/macOS) or Administrator (Windows). +Recommended terminal width: 130+ columns. + +Examples: + sudo ./livetrace 8.8.8.8 + sudo ./livetrace -proto udp -port 33434 8.8.8.8 + sudo ./livetrace -size 1500 8.8.8.8 # MTU path test + sudo ./livetrace -n -count 30 -duration 1m 8.8.8.8 +`) } flag.Parse() @@ -766,11 +929,23 @@ func main() { os.Exit(1) } + p := strings.ToLower(*proto) + if p != "icmp" && p != "udp" { + fmt.Fprintln(os.Stderr, "Error: -proto must be icmp or udp") + os.Exit(1) + } + tracer, err := NewTracer( flag.Arg(0), *maxHops, time.Duration(*intervalMS)*time.Millisecond, time.Duration(*timeoutMS)*time.Millisecond, + p, + *port, + *size, + *count, + *dur, + *noDNS, ) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) @@ -782,7 +957,6 @@ func main() { os.Exit(1) } - // Clear screen once at start. fmt.Print("\033[2J\033[H") sigCh := make(chan os.Signal, 1) @@ -798,6 +972,10 @@ func main() { fmt.Print("\033[2J\033[H") fmt.Println("livetrace stopped.") return + case <-tracer.Done(): + tracer.Render() + fmt.Println("livetrace: trace complete.") + return case <-tick.C: tracer.Render() }