commit bc603c289fa4cadf781b418db918f2ed7e62ea51 Author: Eliezer Croitoru Date: Fri Aug 7 12:27:43 2026 +0300 V1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..03ceb45 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:latest AS builder + +WORKDIR /app/src + +COPY src/ . + +RUN go mod init ngtech.co.il/network/livtrace && go mod tidy + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w" -o /out/myapp-linux-amd64 . + +RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ + go build -ldflags="-s -w" -o /out/myapp-windows-amd64.exe . + +# Stage 2: Export target +FROM scratch AS bin +COPY --from=builder /out/ / diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7d54c72 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: all build clean + +APP_NAME = myapp +OUT_DIR = ./dist + +all: build + +# Builds both binaries inside Docker and exports them locally +build: + @mkdir -p $(OUT_DIR) + DOCKER_BUILDKIT=1 docker build --target bin --output type=local,dest=$(OUT_DIR) . + @echo "Build complete! Binaries located in $(OUT_DIR)" + +clean: + rm -rf $(OUT_DIR) diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..f352e6a --- /dev/null +++ b/src/main.go @@ -0,0 +1,806 @@ +// livetrace - continuous traceroute with live TUI +// Inspired by MikroTik RouterOS traceroute tool. +// Requires root / Administrator (raw ICMP sockets). +// +// Usage: livetrace [-m maxhops] [-i interval_ms] [-t timeout_ms] +// Build: go build -o livetrace . +// Linux: sudo ./livetrace 8.8.8.8 +// Windows (as Administrator): livetrace.exe 8.8.8.8 +// +// Recommended terminal width: 130+ columns. + +package main + +import ( + "encoding/binary" + "flag" + "fmt" + "math" + "net" + "os" + "os/signal" + "runtime" + "sort" + "strings" + "sync" + "syscall" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +const ( + icmpProto = 1 + probeData = "livetrace" + histSize = 25 // number of probe results kept per hop +) + +// 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 +} + +// ─── 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 + sent int + recv int + last float64 // RTT of the most recent reply (ms) + lastTimeout bool // true if the most recent resolved probe timed out + sumRTT float64 + sumRTTSq float64 // Σ(rtt²) — used to compute std deviation online + 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 +} + +func (h *Hop) recordSent() { + h.mu.Lock() + h.sent++ + h.mu.Unlock() +} + +func (h *Hop) recordRecv(rtt float64, from string, reached bool) { + h.mu.Lock() + defer h.mu.Unlock() + + h.recv++ + h.last = rtt + h.lastTimeout = false + h.sumRTT += rtt + h.sumRTTSq += rtt * rtt + if h.best == 0 || rtt < h.best { + h.best = rtt + } + if rtt > h.worst { + h.worst = rtt + } + 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 { + go func(ip string) { + names, err := net.LookupAddr(ip) + h.mu.Lock() + defer h.mu.Unlock() + if err == nil && len(names) > 0 { + h.host = strings.TrimSuffix(names[0], ".") + } + }(from) + } + + 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() + h.lastTimeout = true + h.pushHistory(HistEntry{timedOut: true}) +} + +func (h *Hop) pushHistory(e HistEntry) { + h.history[h.histPos] = e + h.histPos = (h.histPos + 1) % histSize + if h.histLen < histSize { + h.histLen++ + } +} + +// HopSnap is a point-in-time snapshot of a Hop used for rendering. +type HopSnap struct { + TTL int + Addrs []string // sorted list of all responding IPs + Host string + Sent int + Recv int + Last float64 + LastTimeout bool + Avg float64 + Best float64 + Worst float64 + StdDev float64 + Loss float64 + Reached bool + History []HistEntry // chronological slice (oldest → newest) +} + +func (h *Hop) snapshot() HopSnap { + h.mu.Lock() + defer h.mu.Unlock() + + s := HopSnap{ + TTL: h.ttl, + Host: h.host, + Sent: h.sent, + Recv: h.recv, + Last: h.last, + LastTimeout: h.lastTimeout, + Best: h.best, + Worst: h.worst, + Reached: h.reached, + } + + for addr := range h.addrs { + s.Addrs = append(s.Addrs, addr) + } + sort.Strings(s.Addrs) + + 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 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 ─────────────────────────────────────────────────── + +type pending struct { + ttl int + sent time.Time +} + +// ─── Tracer ─────────────────────────────────────────────────────────────────── + +type Tracer struct { + target string + targetIP net.IP + maxHops int + interval time.Duration + timeout time.Duration + hops []*Hop + + conn *icmp.PacketConn + pc *ipv4.PacketConn + pid int + + 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 + + stopCh chan struct{} + started time.Time +} + +func NewTracer(target string, maxHops int, interval, timeout time.Duration) (*Tracer, error) { + ips, err := net.LookupHost(target) + if err != nil { + return nil, fmt.Errorf("resolve %q: %w", target, err) + } + var targetIP net.IP + for _, raw := range ips { + if v4 := net.ParseIP(raw).To4(); v4 != nil { + targetIP = v4 + break + } + } + if targetIP == nil { + return nil, fmt.Errorf("no IPv4 address for %s (IPv6 not yet supported)", target) + } + + hops := make([]*Hop, maxHops) + for i := range hops { + hops[i] = &Hop{ttl: i + 1} + } + + return &Tracer{ + target: target, + targetIP: targetIP, + maxHops: maxHops, + interval: interval, + timeout: timeout, + hops: hops, + pid: os.Getpid() & 0xffff, + probes: make(map[int]*pending), + maxActive: maxHops, + stopCh: make(chan struct{}), + started: time.Now(), + }, nil +} + +func (t *Tracer) Start() error { + var err error + // Try privileged raw socket first. + t.conn, 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") + if err != nil { + hint := "try: sudo ./livetrace" + if runtime.GOOS == "windows" { + hint = "run as Administrator" + } + return fmt.Errorf("cannot open ICMP socket (%s): %w", hint, err) + } + } + t.pc = t.conn.IPv4PacketConn() + + go t.receiveLoop() + go t.probeLoop() + go t.cleanupLoop() + return nil +} + +func (t *Tracer) Stop() { + select { + case <-t.stopCh: + default: + close(t.stopCh) + } + _ = t.conn.Close() +} + +func (t *Tracer) nextSeq() int { + t.mu.Lock() + defer t.mu.Unlock() + t.seqCtr = (t.seqCtr % 0xffff) + 1 + 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() + + msg := icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ID: t.pid, Seq: seq, Data: []byte(probeData)}, + } + b, err := msg.Marshal(nil) + if err != nil { + return + } + + 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.sendMu.Unlock() + + if err != nil { + t.mu.Lock() + delete(t.probes, seq) + t.mu.Unlock() + h := t.hops[ttl-1] + h.mu.Lock() + h.sent-- + h.mu.Unlock() + } +} + +func (t *Tracer) probeLoop() { + for { + t.mu.Lock() + maxActive := t.maxActive + t.mu.Unlock() + + for ttl := 1; ttl <= maxActive; ttl++ { + select { + case <-t.stopCh: + return + default: + } + t.sendProbe(ttl) + time.Sleep(3 * time.Millisecond) + } + + select { + case <-t.stopCh: + return + case <-time.After(t.interval): + } + } +} + +func (t *Tracer) receiveLoop() { + buf := make([]byte, 1500) + for { + select { + case <-t.stopCh: + return + default: + } + + _ = t.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, peer, err := t.conn.ReadFrom(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + select { + case <-t.stopCh: + return + default: + continue + } + } + + from := peerString(peer) + now := time.Now() + + msg, err := icmp.ParseMessage(icmpProto, buf[:n]) + if err != nil { + continue + } + + switch msg.Type { + + case ipv4.ICMPTypeEchoReply: + reply, ok := msg.Body.(*icmp.Echo) + if !ok || reply.ID != t.pid { + continue + } + t.handleReply(reply.Seq, from, now, true) + + case ipv4.ICMPTypeTimeExceeded: + te, ok := msg.Body.(*icmp.TimeExceeded) + if !ok { + continue + } + origSeq, origID, ok := extractOriginalICMP(te.Data) + if !ok || origID != t.pid { + continue + } + t.handleReply(origSeq, from, now, false) + + case ipv4.ICMPTypeDestinationUnreachable: + // In UDP mode, port-unreachable means we reached the destination. + du, ok := msg.Body.(*icmp.DstUnreach) + if !ok { + continue + } + origSeq, origID, ok := extractOriginalICMP(du.Data) + if !ok || origID != t.pid { + continue + } + 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). +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 + if ihl < 20 || len(data) < ihl+8 { + return 0, 0, false + } + orig := data[ihl : ihl+8] + // ICMP header layout: 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) { + t.mu.Lock() + p, ok := t.probes[seq] + if !ok { + t.mu.Unlock() + return + } + delete(t.probes, seq) + rtt := now.Sub(p.sent).Seconds() * 1000 + ttl := p.ttl + t.mu.Unlock() + + if ttl < 1 || ttl > t.maxHops { + return + } + t.hops[ttl-1].recordRecv(rtt, from, reached) + + if reached { + t.mu.Lock() + if t.maxActive > ttl { + t.maxActive = ttl + } + t.mu.Unlock() + } +} + +// cleanupLoop expires probes that never received a reply and records them +// as timeouts in the hop's history ring buffer. +func (t *Tracer) cleanupLoop() { + tick := time.NewTicker(250 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-t.stopCh: + return + case now := <-tick.C: + var expired []int // TTLs whose probes timed out + t.mu.Lock() + for seq, p := range t.probes { + if now.Sub(p.sent) > t.timeout { + expired = append(expired, p.ttl) + delete(t.probes, seq) + } + } + t.mu.Unlock() + + // Record timeouts outside the main lock. + for _, ttl := range expired { + if ttl >= 1 && ttl <= t.maxHops { + t.hops[ttl-1].recordTimeout() + } + } + } + } +} + +// ─── ANSI colour constants ──────────────────────────────────────────────────── + +const ( + colReset = "\033[0m" + colRed = "\033[31m" + colYellow = "\033[33m" + colGreen = "\033[32m" + colCyan = "\033[36m" + colBold = "\033[1m" + 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 + } + + for i, e := range hist { + isLatest := i == len(hist)-1 + + var color, char string + + if e.timedOut { + char = "█" + if isLatest { + color = colCyan + } else { + 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) + if level < 0 { + level = 0 + } + if level > 7 { + level = 7 + } + } + char = string(barChars[level]) + + switch { + case isLatest: + color = colCyan + case best > 0 && e.rtt > best*3: + color = colYellow + default: + 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 + 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 " -" + } + 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 " -" + } + return fmt.Sprintf("%6.1f", v) +} + +func (t *Tracer) Render() { + t.mu.Lock() + maxActive := t.maxActive + t.mu.Unlock() + + elapsed := time.Since(t.started) + + // Reposition cursor to top-left (avoids full-screen flicker). + fmt.Print("\033[H") + + // ── Header ────────────────────────────────────────────────────────────── + 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, + ) + + // ── 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", + colBold, + "Hop", "Host", "Loss%", "Snt", "Rcv", + "Last", "Avg", "Best", "Wrst", "StdD", + fmt.Sprintf("History (%ds window)", histSize), + colReset, + ) + fmt.Println(" " + strings.Repeat("─", 129)) + + 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: + lc, rc = colRed, colReset + case s.Loss >= 10: + lc, rc = colYellow, colReset + case s.Recv > 0: + 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 + 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 + } + + fmt.Printf(" %-4d %-28s %s%5.1f%%%s %4d %4d %s %s %s %s %s %s%s\n", + s.TTL, label, + lc, s.Loss, rc, + s.Sent, s.Recv, + 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 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%02d:%02d", m, s) +} + +func peerString(addr net.Addr) string { + switch a := addr.(type) { + case *net.IPAddr: + return a.IP.String() + case *net.UDPAddr: + return a.IP.String() + default: + return addr.String() + } +} + +// ─── Entry point ───────────────────────────────────────────────────────────── + +func main() { + 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") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: livetrace [options] \n\nOptions:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nRequires root (Linux/macOS) or Administrator (Windows).\n") + fmt.Fprintf(os.Stderr, "Recommended terminal width: 130+ columns.\n") + } + flag.Parse() + + if flag.NArg() < 1 { + flag.Usage() + os.Exit(1) + } + + tracer, err := NewTracer( + flag.Arg(0), + *maxHops, + time.Duration(*intervalMS)*time.Millisecond, + time.Duration(*timeoutMS)*time.Millisecond, + ) + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + + if err := tracer.Start(); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + + // Clear screen once at start. + fmt.Print("\033[2J\033[H") + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + tick := time.NewTicker(200 * time.Millisecond) + defer tick.Stop() + + for { + select { + case <-sigCh: + tracer.Stop() + fmt.Print("\033[2J\033[H") + fmt.Println("livetrace stopped.") + return + case <-tick.C: + tracer.Render() + } + } +} +