V2 release
This commit is contained in:
parent
5b269fe3a0
commit
4d67f8be82
@ -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
|
||||||
|
|||||||
BIN
dist/livetrace-linux-amd64
vendored
BIN
dist/livetrace-linux-amd64
vendored
Binary file not shown.
BIN
dist/livetrace-windows-amd64.exe
vendored
BIN
dist/livetrace-windows-amd64.exe
vendored
Binary file not shown.
508
src/main.go
508
src/main.go
@ -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 (0–7) 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 0–7.
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user