V2 release

This commit is contained in:
Eliezer Croitoru 2026-08-07 12:51:12 +03:00
parent 5b269fe3a0
commit 4d67f8be82
4 changed files with 346 additions and 168 deletions

View File

@ -4,13 +4,13 @@ WORKDIR /app/src
COPY 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 \ 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 \ 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 # Stage 2: Export target
FROM scratch AS bin FROM scratch AS bin

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,11 @@
// livetrace - continuous traceroute with live TUI // livetrace - continuous traceroute with live TUI
// Inspired by MikroTik RouterOS traceroute tool. // Inspired by MikroTik RouterOS traceroute.
// Requires root / Administrator (raw ICMP sockets). // Requires root / Administrator (raw ICMP socket for receiving).
// //
// Usage: livetrace [-m maxhops] [-i interval_ms] [-t timeout_ms] <target> // Usage: livetrace [options] <target>
// Build: go build -o livetrace . // Build: go build -o livetrace .
// Linux: sudo ./livetrace 8.8.8.8 // Linux: sudo ./livetrace 8.8.8.8
// Windows (as Administrator): livetrace.exe 8.8.8.8 // Windows: run as Administrator: livetrace.exe 8.8.8.8
// //
// Recommended terminal width: 130+ columns. // Recommended terminal width: 130+ columns.
@ -31,45 +31,41 @@ import (
) )
const ( const (
icmpProto = 1 icmpProto = 1
probeData = "livetrace" histSize = 25 // history slots per hop
histSize = 25 // number of probe results kept per hop minPktSize = 28 // IP(20) + ICMP/UDP header(8) — zero payload
defPktSize = 56 // matches common ping/traceroute default
) )
// barChars maps a normalized level (07) to a Unicode block character.
var barChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} var barChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
// ─── History ring buffer ────────────────────────────────────────────────────── // ─── 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 { type HistEntry struct {
timedOut bool timedOut bool
rtt float64 // ms; meaningful only when timedOut=false rtt float64 // ms; valid when timedOut=false
} }
// ─── Per-hop statistics ─────────────────────────────────────────────────────── // ─── Per-hop statistics ───────────────────────────────────────────────────────
// Hop tracks all state for one TTL level.
type Hop struct { type Hop struct {
mu sync.Mutex mu sync.Mutex
ttl int ttl int
addrs map[string]bool // all IPs that have responded (ECMP support) noDNS bool
host string // reverse-DNS of the first addr seen addrs map[string]bool // all IPs that responded (ECMP support)
host string // reverse-DNS of first addr
sent int sent int
recv 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 lastTimeout bool // true if the most recent resolved probe timed out
sumRTT float64 sumRTT float64
sumRTTSq float64 // Σ(rtt²) — used to compute std deviation online sumRTTSq float64 // Σ(rtt²) for online std-dev computation
best float64 best float64
worst float64 worst float64
reached bool // true if this hop is the destination reached bool
history [histSize]HistEntry
// Ring buffer: oldest entry is at history[histPos] when histLen==histSize. histLen int
history [histSize]HistEntry histPos int // next write slot (ring buffer)
histLen int // entries filled so far (0 → histSize)
histPos int // index of the next write slot
} }
func (h *Hop) recordSent() { func (h *Hop) recordSent() {
@ -95,15 +91,14 @@ func (h *Hop) recordRecv(rtt float64, from string, reached bool) {
} }
h.reached = reached h.reached = reached
// Track all responding IPs (ECMP load-balanced paths show multiple).
if h.addrs == nil { if h.addrs == nil {
h.addrs = make(map[string]bool) h.addrs = make(map[string]bool)
} }
isNew := !h.addrs[from] isNew := !h.addrs[from]
h.addrs[from] = true h.addrs[from] = true
// Kick off async reverse-DNS only for the very first IP we see. // Async reverse-DNS only for the first IP ever seen, and only if enabled.
if isNew && len(h.addrs) == 1 { if isNew && len(h.addrs) == 1 && !h.noDNS {
go func(ip string) { go func(ip string) {
names, err := net.LookupAddr(ip) names, err := net.LookupAddr(ip)
h.mu.Lock() h.mu.Lock()
@ -117,7 +112,6 @@ func (h *Hop) recordRecv(rtt float64, from string, reached bool) {
h.pushHistory(HistEntry{rtt: rtt}) h.pushHistory(HistEntry{rtt: rtt})
} }
// recordTimeout is called when a probe expires without a reply.
func (h *Hop) recordTimeout() { func (h *Hop) recordTimeout() {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() 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 { type HopSnap struct {
TTL int TTL int
Addrs []string // sorted list of all responding IPs Addrs []string // sorted; multiple = ECMP
Host string Host string
Sent int Sent int
Recv int Recv int
@ -148,7 +142,7 @@ type HopSnap struct {
StdDev float64 StdDev float64
Loss float64 Loss float64
Reached bool Reached bool
History []HistEntry // chronological slice (oldest → newest) History []HistEntry // oldest → newest
} }
func (h *Hop) snapshot() HopSnap { func (h *Hop) snapshot() HopSnap {
@ -166,7 +160,6 @@ func (h *Hop) snapshot() HopSnap {
Worst: h.worst, Worst: h.worst,
Reached: h.reached, Reached: h.reached,
} }
for addr := range h.addrs { for addr := range h.addrs {
s.Addrs = append(s.Addrs, addr) s.Addrs = append(s.Addrs, addr)
} }
@ -174,63 +167,84 @@ func (h *Hop) snapshot() HopSnap {
if h.recv > 0 { if h.recv > 0 {
s.Avg = h.sumRTT / float64(h.recv) s.Avg = h.sumRTT / float64(h.recv)
// Population variance: E[x²] E[x]² if v := h.sumRTTSq/float64(h.recv) - s.Avg*s.Avg; v > 0 {
variance := h.sumRTTSq/float64(h.recv) - s.Avg*s.Avg s.StdDev = math.Sqrt(v)
if variance > 0 {
s.StdDev = math.Sqrt(variance)
} }
} }
if h.sent > 0 { if h.sent > 0 {
s.Loss = float64(h.sent-h.recv) / float64(h.sent) * 100 s.Loss = float64(h.sent-h.recv) / float64(h.sent) * 100
} }
// Copy history in chronological order (oldest → newest).
if h.histLen > 0 { if h.histLen > 0 {
s.History = make([]HistEntry, h.histLen) s.History = make([]HistEntry, h.histLen)
if h.histLen < histSize { if h.histLen < histSize {
copy(s.History, h.history[:h.histLen]) copy(s.History, h.history[:h.histLen])
} else { } else {
// Buffer is full; oldest entry is at h.histPos.
n := copy(s.History, h.history[h.histPos:]) n := copy(s.History, h.history[h.histPos:])
copy(s.History[n:], h.history[:h.histPos]) copy(s.History[n:], h.history[:h.histPos])
} }
} }
return s return s
} }
// ─── Pending probe tracking ─────────────────────────────────────────────────── // ─── Pending probe ────────────────────────────────────────────────────────────
type pending struct { type pending struct {
ttl int ttl int
sent time.Time sent time.Time
port int // UDP destination port (UDP mode); 0 for ICMP mode
} }
// ─── Tracer ─────────────────────────────────────────────────────────────────── // ─── Tracer ───────────────────────────────────────────────────────────────────
type Tracer struct { type Tracer struct {
target string // config
targetIP net.IP target string
maxHops int targetIP net.IP
interval time.Duration maxHops int
timeout time.Duration interval time.Duration
hops []*Hop 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 hops []*Hop
pc *ipv4.PacketConn pid int // used as ICMP Echo identifier
pid int
mu sync.Mutex // sockets
seqCtr int // icmpConn is always opened (for receiving ICMP errors and, in ICMP mode,
probes map[int]*pending // ICMP seq → pending probe // for sending Echo Requests).
maxActive int // don't probe past the destination icmpConn *icmp.PacketConn
sendMu sync.Mutex // serialise SetTTL + WriteTo 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{} stopCh chan struct{}
started time.Time 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) ips, err := net.LookupHost(target)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve %q: %w", target, err) 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) return nil, fmt.Errorf("no IPv4 address for %s (IPv6 not yet supported)", target)
} }
if pktSize < minPktSize {
pktSize = minPktSize
}
hops := make([]*Hop, maxHops) hops := make([]*Hop, maxHops)
for i := range hops { for i := range hops {
hops[i] = &Hop{ttl: i + 1} hops[i] = &Hop{ttl: i + 1, noDNS: noDNS}
} }
return &Tracer{ return &Tracer{
@ -257,22 +275,33 @@ func NewTracer(target string, maxHops int, interval, timeout time.Duration) (*Tr
maxHops: maxHops, maxHops: maxHops,
interval: interval, interval: interval,
timeout: timeout, timeout: timeout,
proto: proto,
basePort: basePort,
pktSize: pktSize,
maxRounds: maxRounds,
maxDur: maxDur,
hops: hops, hops: hops,
pid: os.Getpid() & 0xffff, pid: os.Getpid() & 0xffff,
probes: make(map[int]*pending), probes: make(map[int]*pending),
portToKey: make(map[int]int),
maxActive: maxHops, maxActive: maxHops,
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
started: time.Now(), started: time.Now(),
}, nil }, 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 { func (t *Tracer) Start() error {
var err 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 { if err != nil {
// Fallback: unprivileged ICMP dgram (Linux ≥5.x with ping_group_range set). // Fallback: unprivileged ICMP dgram (Linux ≥5.x with ping_group_range).
t.conn, err = icmp.ListenPacket("udp4", "0.0.0.0:0") t.icmpConn, err = icmp.ListenPacket("udp4", "0.0.0.0:0")
if err != nil { if err != nil {
hint := "try: sudo ./livetrace" hint := "try: sudo ./livetrace"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@ -281,7 +310,17 @@ func (t *Tracer) Start() error {
return fmt.Errorf("cannot open ICMP socket (%s): %w", hint, err) 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.receiveLoop()
go t.probeLoop() go t.probeLoop()
@ -295,7 +334,10 @@ func (t *Tracer) Stop() {
default: default:
close(t.stopCh) close(t.stopCh)
} }
_ = t.conn.Close() _ = t.icmpConn.Close()
if t.udpSend != nil {
_ = t.udpSend.Close()
}
} }
func (t *Tracer) nextSeq() int { func (t *Tracer) nextSeq() int {
@ -305,17 +347,55 @@ func (t *Tracer) nextSeq() int {
return t.seqCtr return t.seqCtr
} }
// sendProbe sends one ICMP Echo Request with the given TTL. // udpPortForKey maps a sequential probe key to a UDP destination port.
// SetTTL + WriteTo are serialised with sendMu to avoid a socket-option race. func (t *Tracer) udpPortForKey(key int) int {
func (t *Tracer) sendProbe(ttl int) { spread := 65535 - t.basePort
seq := t.nextSeq() 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{ msg := icmp.Message{
Type: ipv4.ICMPTypeEcho, Type: ipv4.ICMPTypeEcho,
Code: 0, 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 { if err != nil {
return return
} }
@ -323,12 +403,11 @@ func (t *Tracer) sendProbe(ttl int) {
t.mu.Lock() t.mu.Lock()
t.probes[seq] = &pending{ttl: ttl, sent: time.Now()} t.probes[seq] = &pending{ttl: ttl, sent: time.Now()}
t.mu.Unlock() t.mu.Unlock()
t.hops[ttl-1].recordSent() t.hops[ttl-1].recordSent()
t.sendMu.Lock() t.sendMu.Lock()
_ = t.pc.SetTTL(ttl) _ = t.icmpPC.SetTTL(ttl)
_, err = t.conn.WriteTo(b, &net.IPAddr{IP: t.targetIP}) _, err = t.icmpConn.WriteTo(b, &net.IPAddr{IP: t.targetIP})
t.sendMu.Unlock() t.sendMu.Unlock()
if err != nil { 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() { func (t *Tracer) probeLoop() {
round := 0
for { 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() t.mu.Lock()
maxActive := t.maxActive maxActive := t.maxActive
t.mu.Unlock() t.mu.Unlock()
@ -357,6 +477,7 @@ func (t *Tracer) probeLoop() {
t.sendProbe(ttl) t.sendProbe(ttl)
time.Sleep(3 * time.Millisecond) time.Sleep(3 * time.Millisecond)
} }
round++
select { select {
case <-t.stopCh: case <-t.stopCh:
@ -366,6 +487,8 @@ func (t *Tracer) probeLoop() {
} }
} }
// ─── Receive loop ─────────────────────────────────────────────────────────────
func (t *Tracer) receiveLoop() { func (t *Tracer) receiveLoop() {
buf := make([]byte, 1500) buf := make([]byte, 1500)
for { for {
@ -375,8 +498,8 @@ func (t *Tracer) receiveLoop() {
default: default:
} }
_ = t.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) _ = t.icmpConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
n, peer, err := t.conn.ReadFrom(buf) n, peer, err := t.icmpConn.ReadFrom(buf)
if err != nil { if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() { if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue continue
@ -400,6 +523,10 @@ func (t *Tracer) receiveLoop() {
switch msg.Type { switch msg.Type {
case ipv4.ICMPTypeEchoReply: case ipv4.ICMPTypeEchoReply:
// Only relevant in ICMP mode.
if t.proto != "icmp" {
continue
}
reply, ok := msg.Body.(*icmp.Echo) reply, ok := msg.Body.(*icmp.Echo)
if !ok || reply.ID != t.pid { if !ok || reply.ID != t.pid {
continue continue
@ -411,52 +538,109 @@ func (t *Tracer) receiveLoop() {
if !ok { if !ok {
continue continue
} }
origSeq, origID, ok := extractOriginalICMP(te.Data) if t.proto == "udp" {
if !ok || origID != t.pid { dstPort, ok := extractOriginalUDPPort(te.Data)
continue 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: 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) du, ok := msg.Body.(*icmp.DstUnreach)
if !ok { if !ok {
continue continue
} }
origSeq, origID, ok := extractOriginalICMP(du.Data) if t.proto == "udp" {
if !ok || origID != t.pid { dstPort, ok := extractOriginalUDPPort(du.Data)
continue 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 // ─── ICMP error payload extraction ───────────────────────────────────────────
// embedded in ICMP error messages (Time Exceeded, Dest Unreachable).
// 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) { func extractOriginalICMP(data []byte) (seq, id int, ok bool) {
if len(data) < 28 { if len(data) < 28 {
return 0, 0, false 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 { if ihl < 20 || len(data) < ihl+8 {
return 0, 0, false return 0, 0, false
} }
if data[9] != 1 { // protocol must be ICMP (1)
return 0, 0, false
}
orig := data[ihl : ihl+8] 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])) id = int(binary.BigEndian.Uint16(orig[4:6]))
seq = int(binary.BigEndian.Uint16(orig[6:8])) seq = int(binary.BigEndian.Uint16(orig[6:8]))
return seq, id, true 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() t.mu.Lock()
p, ok := t.probes[seq] p, ok := t.probes[key]
if !ok { if !ok {
t.mu.Unlock() t.mu.Unlock()
return 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 rtt := now.Sub(p.sent).Seconds() * 1000
ttl := p.ttl ttl := p.ttl
t.mu.Unlock() 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 // ─── Cleanup loop ─────────────────────────────────────────────────────────────
// as timeouts in the hop's history ring buffer.
// 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() { func (t *Tracer) cleanupLoop() {
tick := time.NewTicker(250 * time.Millisecond) tick := time.NewTicker(250 * time.Millisecond)
defer tick.Stop() defer tick.Stop()
@ -485,27 +675,30 @@ func (t *Tracer) cleanupLoop() {
case <-t.stopCh: case <-t.stopCh:
return return
case now := <-tick.C: case now := <-tick.C:
var expired []int // TTLs whose probes timed out var expired []expiredProbe
t.mu.Lock() t.mu.Lock()
for seq, p := range t.probes { for key, p := range t.probes {
if now.Sub(p.sent) > t.timeout { if now.Sub(p.sent) > t.timeout {
expired = append(expired, p.ttl) expired = append(expired, expiredProbe{ttl: p.ttl, port: p.port})
delete(t.probes, seq) if p.port != 0 {
delete(t.portToKey, p.port)
}
delete(t.probes, key)
} }
} }
t.mu.Unlock() t.mu.Unlock()
// Record timeouts outside the main lock. // Record timeouts outside the lock so recordTimeout can acquire h.mu freely.
for _, ttl := range expired { for _, e := range expired {
if ttl >= 1 && ttl <= t.maxHops { if e.ttl >= 1 && e.ttl <= t.maxHops {
t.hops[ttl-1].recordTimeout() t.hops[e.ttl-1].recordTimeout()
} }
} }
} }
} }
} }
// ─── ANSI colour constants ──────────────────────────────────────────────────── // ─── TUI display ─────────────────────────────────────────────────────────────
const ( const (
colReset = "\033[0m" colReset = "\033[0m"
@ -517,35 +710,20 @@ const (
colDim = "\033[2m" 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 { func renderHistory(hist []HistEntry, best, worst float64) string {
var sb strings.Builder var sb strings.Builder
// Left-pad with spaces until the buffer fills up.
for i := 0; i < histSize-len(hist); i++ { for i := 0; i < histSize-len(hist); i++ {
sb.WriteByte(' ') sb.WriteByte(' ')
} }
rng := worst - best rng := worst - best
if rng < 1 { if rng < 1 {
rng = 1 // avoid divide-by-zero; all bars will be at minimum height rng = 1
} }
for i, e := range hist { for i, e := range hist {
isLatest := i == len(hist)-1 isLatest := i == len(hist)-1
var color, char string var color, char string
if e.timedOut { if e.timedOut {
@ -556,11 +734,10 @@ func renderHistory(hist []HistEntry, best, worst float64) string {
color = colRed color = colRed
} }
} else { } else {
// Map RTT onto 07.
level := 0 level := 0
if best > 0 && worst > best { if best > 0 && worst > best {
norm := (e.rtt - best) / rng norm := (e.rtt - best) / rng
level = int(norm * 8) // 0.0→0, 1.0→8 (clamped below) level = int(norm * 8)
if level < 0 { if level < 0 {
level = 0 level = 0
} }
@ -569,7 +746,6 @@ func renderHistory(hist []HistEntry, best, worst float64) string {
} }
} }
char = string(barChars[level]) char = string(barChars[level])
switch { switch {
case isLatest: case isLatest:
color = colCyan color = colCyan
@ -579,46 +755,33 @@ func renderHistory(hist []HistEntry, best, worst float64) string {
color = colGreen color = colGreen
} }
} }
sb.WriteString(color + char + colReset) sb.WriteString(color + char + colReset)
} }
return sb.String() 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 { func formatHost(s HopSnap, maxLen int) string {
if len(s.Addrs) == 0 { if len(s.Addrs) == 0 {
return "???" return "???"
} }
primary := s.Addrs[0] primary := s.Addrs[0]
if s.Host != "" { if s.Host != "" {
primary = s.Host primary = s.Host
} }
if len(s.Addrs) == 1 { if len(s.Addrs) == 1 {
if len(primary) > maxLen { if len(primary) > maxLen {
return primary[:maxLen-3] + "..." return primary[:maxLen-3] + "..."
} }
return primary return primary
} }
// ECMP: comma-separate all IPs after the primary label.
// ECMP: show all IPs comma-separated after the primary label. full := primary + ", " + strings.Join(s.Addrs[1:], ", ")
extras := strings.Join(s.Addrs[1:], ", ")
full := primary + ", " + extras
if len(full) > maxLen { if len(full) > maxLen {
return full[:maxLen-3] + "..." return full[:maxLen-3] + "..."
} }
return full 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 { func fms(v float64) string {
if v == 0 { if v == 0 {
return " -" return " -"
@ -626,7 +789,6 @@ func fms(v float64) string {
return fmt.Sprintf("%7.1f", v) return fmt.Sprintf("%7.1f", v)
} }
// fdev formats a standard deviation value as a 6-character string.
func fdev(v float64) string { func fdev(v float64) string {
if v == 0 { if v == 0 {
return " -" return " -"
@ -640,30 +802,24 @@ func (t *Tracer) Render() {
t.mu.Unlock() t.mu.Unlock()
elapsed := time.Since(t.started) elapsed := time.Since(t.started)
// Reposition cursor to top-left (avoids full-screen flicker).
fmt.Print("\033[H") fmt.Print("\033[H")
// ── Header ────────────────────────────────────────────────────────────── // Header line
label := t.target label := t.target
if label != t.targetIP.String() { if label != t.targetIP.String() {
label = fmt.Sprintf("%s (%s)", t.target, t.targetIP) label = fmt.Sprintf("%s (%s)", t.target, t.targetIP)
} }
fmt.Printf("%sLiveTrace%s → %-44s %s %s[q] quit%s\n\n", protoTag := fmt.Sprintf("[%s/%dB]", strings.ToUpper(t.proto), t.pktSize)
colBold, colReset, label, formatElapsed(elapsed), colDim, colReset, 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 ─────────────────────────────────────────────────────── // 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
fmt.Printf(" %s%-4s %-28s %6s %4s %4s %7s %7s %7s %7s %6s %-25s%s\n", fmt.Printf(" %s%-4s %-28s %6s %4s %4s %7s %7s %7s %7s %6s %-25s%s\n",
colBold, colBold,
"Hop", "Host", "Loss%", "Snt", "Rcv", "Hop", "Host", "Loss%", "Snt", "Rcv",
"Last", "Avg", "Best", "Wrst", "StdD", "Last", "Avg", "Best", "Wrst", "StdD",
fmt.Sprintf("History (%ds window)", histSize), fmt.Sprintf("History (%ds)", histSize),
colReset, colReset,
) )
fmt.Println(" " + strings.Repeat("─", 129)) fmt.Println(" " + strings.Repeat("─", 129))
@ -671,14 +827,12 @@ func (t *Tracer) Render() {
for i := 0; i < maxActive && i < len(t.hops); i++ { for i := 0; i < maxActive && i < len(t.hops); i++ {
renderHopRow(t.hops[i].snapshot()) renderHopRow(t.hops[i].snapshot())
} }
fmt.Println() fmt.Println()
} }
func renderHopRow(s HopSnap) { func renderHopRow(s HopSnap) {
label := formatHost(s, 28) label := formatHost(s, 28)
// Loss column colour.
lc, rc := "", "" lc, rc := "", ""
switch { switch {
case s.Loss >= 50: case s.Loss >= 50:
@ -689,22 +843,18 @@ func renderHopRow(s HopSnap) {
lc, rc = colGreen, colReset 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 var lastStr string
switch { switch {
case s.LastTimeout: case s.LastTimeout:
lastStr = "timeout" // 7 chars — matches fms width lastStr = "timeout"
case s.Last > 0: case s.Last > 0:
lastStr = fmt.Sprintf("%7.1f", s.Last) lastStr = fmt.Sprintf("%7.1f", s.Last)
default: default:
lastStr = " -" lastStr = " -"
} }
// History bar chart (pre-coloured string, histSize wide).
histStr := renderHistory(s.History, s.Best, s.Worst) histStr := renderHistory(s.History, s.Best, s.Worst)
// Destination marker.
reachedStr := "" reachedStr := ""
if s.Reached { if s.Reached {
reachedStr = colGreen + " ✓" + colReset reachedStr = colGreen + " ✓" + colReset
@ -714,18 +864,13 @@ func renderHopRow(s HopSnap) {
s.TTL, label, s.TTL, label,
lc, s.Loss, rc, lc, s.Loss, rc,
s.Sent, s.Recv, s.Sent, s.Recv,
lastStr, lastStr, fms(s.Avg), fms(s.Best), fms(s.Worst),
fms(s.Avg),
fms(s.Best),
fms(s.Worst),
fdev(s.StdDev), fdev(s.StdDev),
histStr, histStr,
reachedStr, reachedStr,
) )
} }
// ─── Utilities ────────────────────────────────────────────────────────────────
func formatElapsed(d time.Duration) string { func formatElapsed(d time.Duration) string {
h := int(d.Hours()) h := int(d.Hours())
m := int(d.Minutes()) % 60 m := int(d.Minutes()) % 60
@ -750,14 +895,32 @@ func peerString(addr net.Addr) string {
// ─── Entry point ───────────────────────────────────────────────────────────── // ─── Entry point ─────────────────────────────────────────────────────────────
func main() { 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") 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() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: livetrace [options] <target>\n\nOptions:\n") fmt.Fprintf(os.Stderr, `Usage: livetrace [options] <target>
Options:
`)
flag.PrintDefaults() flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nRequires root (Linux/macOS) or Administrator (Windows).\n") fmt.Fprintf(os.Stderr, `
fmt.Fprintf(os.Stderr, "Recommended terminal width: 130+ columns.\n") 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() flag.Parse()
@ -766,11 +929,23 @@ func main() {
os.Exit(1) 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( tracer, err := NewTracer(
flag.Arg(0), flag.Arg(0),
*maxHops, *maxHops,
time.Duration(*intervalMS)*time.Millisecond, time.Duration(*intervalMS)*time.Millisecond,
time.Duration(*timeoutMS)*time.Millisecond, time.Duration(*timeoutMS)*time.Millisecond,
p,
*port,
*size,
*count,
*dur,
*noDNS,
) )
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err) fmt.Fprintln(os.Stderr, "Error:", err)
@ -782,7 +957,6 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Clear screen once at start.
fmt.Print("\033[2J\033[H") fmt.Print("\033[2J\033[H")
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@ -798,6 +972,10 @@ func main() {
fmt.Print("\033[2J\033[H") fmt.Print("\033[2J\033[H")
fmt.Println("livetrace stopped.") fmt.Println("livetrace stopped.")
return return
case <-tracer.Done():
tracer.Render()
fmt.Println("livetrace: trace complete.")
return
case <-tick.C: case <-tick.C:
tracer.Render() tracer.Render()
} }