This commit is contained in:
Eliezer Croitoru 2026-08-07 12:27:43 +03:00
commit bc603c289f
3 changed files with 838 additions and 0 deletions

17
Dockerfile Normal file
View File

@ -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/ /

15
Makefile Normal file
View File

@ -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)

806
src/main.go Normal file
View File

@ -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] <target>
// 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 (07) 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 07.
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] <target>\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()
}
}
}