A traceroute implementation built from scratch in Go as a learning project.
# Build
go build ./cmd/traceroute
# Run (requires root for raw ICMP sockets)
sudo ./traceroute [flags] <target>
# Examples
sudo ./traceroute google.com
sudo ./traceroute -m 15 -q 3 8.8.8.8
sudo ./traceroute -n cloudflare.com # Disable DNS lookup| Flag | Default | Description |
|---|---|---|
-m |
30 | Maximum number of hops |
-w |
2 | Timeout per probe (seconds) |
-q |
3 | Number of probes per hop |
-n |
false | Disable reverse DNS lookup |
1 192.168.1.1 (router.local) 4ms 3ms 5ms
2 10.0.0.1 (isp-gateway.net) 8ms 7ms 9ms
3 * * * *
4 172.217.14.206 (lhr25s10.net) 12ms 11ms 13ms
The core trick is the TTL (Time To Live) field in IP packets:
- Send packet with TTL=1 -> First router decrements to 0, drops it, sends back ICMP Time Exceeded
- Send packet with TTL=2 -> First router forwards (TTL=1), second router drops it, sends Time Exceeded
- Keep incrementing until you get ICMP Echo Reply (destination reached)
Each router that drops the packet reveals its IP address in the ICMP response.
Goroutines and Channels:
- Listener runs in a separate goroutine, continuously reading packets
- Main loop sends probes and waits on channels for responses
- Channels act as "mailboxes" - each probe registers a channel, listener delivers to it
The Registry Pattern:
Main goroutine: Listener goroutine:
| |
|-- Register(seq) -> channel |
|-- Send probe |
| |-- Receives reply
| |-- Looks up channel by seq
| |-- Delivers result
|-- Receives from channel <---------|
|-- Unregister(seq) |
sync.WaitGroup for tracking goroutines:
wg.Add(1)before starting goroutinewg.Done()when goroutine finisheswg.Wait()to block until all goroutines complete
Context provides a clean way to signal "stop working":
ctx, cancel := context.WithCancel(context.Background())
// Pass ctx to all goroutines
go StartListener(ctx, ...)
// When done, call cancel()
cancel() // All goroutines checking ctx.Done() will exit
The pattern in listener:
- Set short read deadline (100ms)
- After timeout, check
ctx.Done() - If cancelled, return cleanly
- If not, continue reading
The Problem: net.LookupAddr makes real DNS calls. Can't test easily.
The Solution: Dependency Injection via interfaces.
type Resolver interface {
LookupAddr(string) ([]string, error)
}
// Production: uses real DNS
type NetResolver struct{}
func (n NetResolver) LookupAddr(addr string) ([]string, error) {
return net.LookupAddr(addr)
}
// Tests: returns whatever you configure
type MockResolver struct {
Names []string
Err error
}
func (m MockResolver) LookupAddr(addr string) ([]string, error) {
return m.Names, m.Err
}The Rule: Accept interfaces, return structs.
traceroute/
├── cmd/
│ └── traceroute/
│ └── main.go # CLI entry point, flag parsing
├── internal/
│ └── tracer/
│ ├── tracer.go # Main tracing logic
│ ├── listener.go # ICMP packet receiver
│ ├── registry.go # Probe correlation
│ ├── resolver.go # DNS lookup interface
│ └── config.go # Configuration struct
└── go.mod
cmd/<name>/main.go- entry points for binariesinternal/- private packages, can't be imported by others- Keep
main.gothin - just wiring, no business logic
Instead of writing repetitive tests:
func TestEncodeSequence(t *testing.T) {
tests := []struct {
name string
ttl int
probe int
probesPerHop int
expected int
}{
{"ttl=1, probe=0", 1, 0, 3, 0},
{"ttl=1, probe=1", 1, 1, 3, 1},
{"ttl=2, probe=0", 2, 0, 3, 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EncodeSequence(tt.ttl, tt.probesPerHop, tt.probe)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}Graceful shutdown on Ctrl+C:
func handleSignals(cancel context.CancelFunc) {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs // Block until signal
cancel() // Trigger cancellation
}- Opened ICMP socket with
icmp.ListenPacket - Sent Echo Request to localhost
- Received and parsed Echo Reply
- Key learning: Understanding
golang.org/x/net/icmpandipv4packages
- Used
ipv4.PacketConn.SetTTL()to control hop count - Sent probes with incrementing TTL
- Parsed Time Exceeded messages from intermediate routers
- Key learning: Extracting sequence number from embedded ICMP header in Time Exceeded
- Moved packet reading to separate goroutine
- Created ProbeRegistry with map[seq]channel
- Implemented Register/Deliver/Unregister pattern
- Key learning: Go's channel-based coordination between goroutines
- Store
time.Now()when registering probe - Compute
time.Since(sentAt)when delivering result - Key learning: Measuring time across goroutines
- Used
flagpackage for-m,-w,-qflags - Positional argument from
flag.Args() net.ResolveIPAddr()for hostname resolution- Key learning: Go's flag parsing, positional args come after
flag.Parse()
- Inner loop for N probes per TTL
- Unique sequence numbers:
seq = (ttl-1)*probesPerHop + probe - Collect results into slice before printing
- Key learning: Separating data collection from output
- Added
context.Contextthroughout - Listener checks
ctx.Done()after read timeouts - Signal handler calls
cancel()on Ctrl+C - Key learning: Context propagation for cancellation
- Created
Resolverinterface for testability net.LookupAddr()for PTR records- Format:
IP (hostname)or justIPif no PTR - Key learning: Dependency injection via interfaces
- Table-driven tests for sequence encoding
- Mock resolver for DNS tests
- WaitGroup + timeout pattern for listener cancellation test
- Key learning: Testing concurrent code, mocking external dependencies
┌─────────────────────────────────────────────────────────────┐
│ main.go │
│ - Parse flags │
│ - Create context │
│ - Setup signal handler │
│ - Call ListenTrace() │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ListenTrace() │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────┐ │
│ │ Listener │ │ Main Loop │ │
│ │ goroutine │ │ │ │
│ │ │ │ for ttl := 1 to maxHops: │ │
│ │ - ReadFrom │ │ for probe := 0 to N: │ │
│ │ - Parse │ │ Register(seq) │ │
│ │ - Deliver │────▶│ Send probe │ │
│ │ │ │ Wait on channel │ │
│ └─────────────┘ │ Collect result │ │
│ ▲ │ Print hop results │ │
│ │ │ Check if destination │ │
│ │ └─────────────────────────────────┘ │
│ │ │
│ ┌──────┴──────────────────────────────────────────────┐ │
│ │ ProbeRegistry │ │
│ │ map[seq] -> { channel, sentAt } │ │
│ │ Register() / Deliver() / Unregister() │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
# Run all tests
go test ./internal/tracer -v
# Run specific test
go test ./internal/tracer -v -run "TestDNSResolver"
# Run listener test (requires root)
sudo go test ./internal/tracer -v -run "TestListenerCancellation"| File | Purpose |
|---|---|
cmd/traceroute/main.go |
CLI entry point, flag parsing, signal handling |
internal/tracer/tracer.go |
Main tracing loop, probe sending |
internal/tracer/listener.go |
ICMP packet receiver, message parsing |
internal/tracer/registry.go |
Probe correlation, RTT tracking |
internal/tracer/resolver.go |
DNS lookup interface |
internal/tracer/config.go |
Configuration struct |