Go Network Programming Series: Implementing Traceroute in Golang

This is the first post in my Network Programming series. I wanted to really understand how networking tools work under the hood, so I decided to start by building traceroute from scratch in Go. The full source code is on GitHub.

Note: Both the code and this article are initial drafts. I'll be refining both over time as the series progresses.

We all use traceroute (or tracert on Windows) to debug network paths, but have you ever wondered what's actually happening on the wire? Let's break it down.

The Core Idea: Sending UDP Packets at High Numbered Ports and Playing with the IP TTL Field

Every IP packet has a field called TTL (Time To Live). Despite the name, it's not really about time — it's a hop counter. Every router that forwards a packet decrements the TTL by 1. When the TTL hits zero, the router drops the packet and sends back an ICMP Time Exceeded message to the sender.

Traceroute exploits this beautifully:

  1. Send a packet with TTL=1 → the first router drops it and replies with ICMP Time Exceeded, revealing its IP.
  2. Send a packet with TTL=2 → the second router does the same.
  3. Keep incrementing TTL until you reach the destination.

When the packet finally reaches the destination, the destination itself responds — but with a different ICMP message: Destination Unreachable (Port Unreachable), because we deliberately target a high, unlikely UDP port. That's our signal to stop.

The Architecture

My implementation has three concurrent components talking through Go channels:

  1. Probe Sender — sends UDP packets with incrementing TTLs
  2. ICMP Listener — listens for all incoming ICMP responses
  3. Correlator — matches ICMP responses back to the original probes

Here's a high-level view of the data flow:

Probe Sender ──→ UDP packets (TTL 1,2,3...) ──→ Network
                                                    │
                                                    ▼
ICMP Listener ←── ICMP Time Exceeded / Dest Unreachable
       │
       ▼
  Correlator ──→ matches probe → computes RTT → prints hop

Sending Probes: The UDP Trick

Unlike ping which uses ICMP echo requests, the classic Unix traceroute sends UDP packets to high-numbered ports. Here's the probe implementation:

type Probe struct {
    DstAddr string
    Port    int
    TTL     int
    Seq     int
    SentAt  time.Time
}

func NewProbe(dst string, ttl int, seq int, basePort int) Probe {
    return Probe{
        DstAddr: dst,
        Port:    basePort + ttl*3 + seq,
        TTL:     ttl,
        Seq:     seq,
        SentAt:  time.Now(),
    }
}

Notice the port encoding: basePort + ttl*3 + seq. The base port is 33435 (the traditional traceroute starting port). We send 3 probes per TTL (seq 0, 1, 2), each on a unique port. This is key — the port number becomes our probe identifier, letting us correlate ICMP responses back to the original probe.

Sending the probe is straightforward — create a UDP connection, set the TTL using the ipv4 package, and fire off an empty payload:

func (p *Probe) Send() error {
    dst := &net.UDPAddr{
        IP:   net.ParseIP(p.DstAddr),
        Port: p.Port,
    }

    conn, err := net.DialUDP("udp4", nil, dst)
    if err != nil {
        return fmt.Errorf("dial error: %w", err)
    }
    defer conn.Close()

    pconn := ipv4.NewPacketConn(conn)
    if err := pconn.SetTTL(p.TTL); err != nil {
        return fmt.Errorf("set TTL error: %w", err)
    }

    payload := make([]byte, 12)
    _, err = conn.Write(payload)
    if err != nil {
        return fmt.Errorf("write error: %w", err)
    }
    return nil
}

Listening for ICMP Responses

We open a raw ICMP socket to listen for all ICMP packets arriving at our machine. The listener runs in its own goroutine and pushes parsed ICMP messages into a channel:

conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")

The listener uses a read deadline pattern so it can periodically check for context cancellation — this lets us cleanly shut down when we're done:

func listenICMP(ctx context.Context, conn *icmp.PacketConn, out chan<- ICMPEvent) {
    buf := make([]byte, 1500)
    for {
        select {
        case <-ctx.Done():
            return
        default:
        }

        conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
        n, from, err := conn.ReadFrom(buf)
        if err != nil {
            if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
                continue
            }
            log.Printf("read error: %v\n", err)
            return
        }

        msg, err := icmp.ParseMessage(1, buf[:n])
        if err != nil {
            continue
        }

        out <- ICMPEvent{From: from, Msg: msg}
    }
}

The Clever Part: Correlating Responses

Here's where it gets interesting. When a router sends back an ICMP Time Exceeded message, it includes the first 8 bytes of the original UDP packet inside the ICMP payload. Those 8 bytes contain the UDP source and destination port.

Since we encoded the TTL and sequence number into the destination port, we can extract it from the ICMP response and match it back to our probe:

func ExtractPort(msg *icmp.Message) string {
    var data []byte

    switch msg.Type {
    case ipv4.ICMPTypeTimeExceeded:
        body, ok := msg.Body.(*icmp.TimeExceeded)
        if !ok { return "" }
        data = body.Data

    case ipv4.ICMPTypeDestinationUnreachable:
        body, ok := msg.Body.(*icmp.DstUnreach)
        if !ok { return "" }
        data = body.Data

    default:
        return ""
    }

    ipHeader, err := ipv4.ParseHeader(data)
    if err != nil { return "" }

    offset := ipHeader.Len
    if len(data) < offset+4 { return "" }

    dstPort := binary.BigEndian.Uint16(data[offset+2 : offset+4])
    return fmt.Sprintf("%d", dstPort)
}

The ICMP body contains the original IP header + first 8 bytes of the UDP datagram. We parse the IP header to find its length, skip past it, and read bytes 2-4 of the UDP header which hold the destination port.

Here's a Wireshark capture showing exactly this — you can see the ICMP Time Exceeded message containing the original UDP packet with our encoded destination port:

Wireshark capture showing ICMP Time Exceeded response containing the original UDP probe packet with encoded destination port

Putting It All Together

The main function wires up the three components with Go channels. Here's the high-level flow:

func main() {
    conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
    // ... error handling ...

    ctx, cancel := context.WithCancel(context.Background())
    probeChan := make(chan probe.Probe)
    icmpEventChan := make(chan ICMPEvent)
    HopChan := make(chan HopResult)

    // 1. ICMP Listener goroutine
    wg.Go(func() { listenICMP(ctx, conn, icmpEventChan) })

    // 2. Probe Sender goroutine — fires every 500ms
    go func() {
        ticker := time.NewTicker(5000 * time.Millisecond)
        seq := 0; ttl := 1
        for {
            select {
            case <-ctx.Done(): return
            case <-ticker.C:
                p := probe.NewProbe("8.8.8.8", ttl, seq, 33435)
                p.Send()
                probeChan <- p
                seq++
                if seq > 2 { seq = 0; ttl++ }
                if ttl > MAX_TTL { cancel() }
            }
        }
    }()

    // 3. Correlator goroutine
    go func() {
        probeMap := make(map[string]probe.Probe)
        for {
            select {
            case event := <-icmpEventChan:
                probePort := probe.ExtractPort(event.Msg)
                v, ok := probeMap[probePort]
                if ok {
                    duration := time.Since(v.SentAt)
                    t, _ := strconv.Atoi(probePort)
                    t = t - 33435
                    HopChan <- HopResult{t / 3, duration, event.From}
                }
                // Destination reached!
                if event.Msg.Type == ipv4.ICMPTypeDestinationUnreachable &&
                   event.Msg.Code == 3 {
                    cancel()
                    return
                }
            case pb := <-probeChan:
                probeMap[fmt.Sprintf("%d", pb.Port)] = pb
            }
        }
    }()

    wg.Wait()
}

How We Know We've Arrived

The stop condition is elegant. When our UDP probe actually reaches the destination (e.g., 8.8.8.8), the destination machine sees a UDP packet on a random high port where nothing is listening. It responds with ICMP Destination Unreachable, Code 3 (Port Unreachable).

We check for exactly this in the correlator:

if event.Msg.Type == ipv4.ICMPTypeDestinationUnreachable && event.Msg.Code == 3 {
    cancel()
    return
}

The Port 30000 Rabbit Hole

One interesting challenge I ran into — I initially started with port 30000 as my base port instead of 33435. Everything seemed to work fine for the intermediate hops (I was getting ICMP Time Exceeded responses from routers along the path), but the traceroute would never terminate. The destination host simply wasn't sending back an ICMP Port Unreachable message.

When I implemented the code and started testing, I had chosen 30,000 as the UDP destination port. I was getting Time Exceeded from intermediary routers — what I was not getting was the Port Unreachable from my intended destination. I immediately tried traceroute and it was working fine. At that point I fired up my Wireshark and started noting the packets. The only difference was the starting port: 33435. I changed the port and boom — my code started working.

I don't know the exact reason, but I suspect maybe firewalls only send ICMP traffic for the 33435+ range ports and drop it for other ports, even though they should be sending the Port Unreachable code — just to keep traceroute running and ignoring other packets.

Running It

Since we're opening raw ICMP sockets, you need root privileges:

sudo go run main.go

Sample output tracing to 8.8.8.8:

Listening for ICMP packets on 0.0.0.0 ...
1,192.168.1.1,2.341ms
2,10.194.0.1,8.712ms
3,72.14.216.72,12.456ms
4,108.170.251.33,14.231ms
5,8.8.8.8,15.892ms
🎯 Destination reached!

Key Takeaways

  • TTL is the mechanism — each hop decrements it; when it hits 0, the router tells us who it is.
  • UDP + high ports — we send to ports nothing listens on, so the destination replies with Port Unreachable.
  • Port encoding — encoding TTL and sequence into the port number gives us a cheap correlation ID without needing to track packet IDs.
  • ICMP carries the evidence — both Time Exceeded and Destination Unreachable messages include the original IP header + 8 bytes of the triggering packet, letting us extract the port and match it back.
  • Go's concurrency model — channels and goroutines make it natural to express the probe-listen-correlate pipeline.

A Note on the Code

Fair warning — the code is a mess right now, and I'm okay with that. The whole point of this exercise was to learn by doing: get hands-on with goroutines, channels, and raw sockets. Not to produce production-grade code. I'll refine it over time as the series progresses.

Also worth mentioning — I didn't use AI to write this code. The implementation is all hand-written. I only used AI assistance afterwards to clean up some of the rough edges. I think that distinction matters when you're trying to actually learn how something works.

What's Next

In the next post in this series, we'll explore more network programming concepts. The full source code is available on GitHub.