Frontend Labs
⋯

Day 34 — UDP

Learning Objectives

  • Build a statsd-compatible metrics receiver — understand UDP's fire-and-forget nature
  • Understand why UDP is used for DDoS amplification attacks
  • Handle packet loss gracefully

Key Concepts

TCP vs UDP:
  TCP: connection-oriented, ordered, reliable, flow-controlled
  UDP: connectionless, unordered, unreliable, no flow control

Why UDP for metrics (statsd, syslog):
  - Sending metrics should never block the application
  - Lost metrics are acceptable; blocking is not
  - 10k packets/sec is normal; TCP overhead would be significant

Why UDP enables DDoS amplification:
  - Attacker spoofs source IP (UDP has no handshake to verify)
  - Sends small request to DNS/NTP server
  - Server sends large response to victim (the spoofed IP)
  - Amplification factor: DNS up to 70x, NTP up to 556x

Exercise: statsd-Compatible Metrics Receiver

package main

import (
    "fmt"
    "net"
    "strconv"
    "strings"
    "sync"
    "time"
)

type Metric struct {
    Name  string
    Value float64
    Type  string // c=counter, g=gauge, ms=timer
}

type StatsReceiver struct {
    conn     *net.UDPConn
    mu       sync.Mutex
    counters map[string]float64
    gauges   map[string]float64
    timers   map[string][]float64
    received int64
    dropped  int64
}

func NewStatsReceiver(addr string) (*StatsReceiver, error) {
    udpAddr, err := net.ResolveUDPAddr("udp", addr)
    if err != nil {
        return nil, err
    }
    conn, err := net.ListenUDP("udp", udpAddr)
    if err != nil {
        return nil, err
    }
    // Set receive buffer — important for burst handling
    conn.SetReadBuffer(1024 * 1024) // 1MB
    return &StatsReceiver{
        conn:     conn,
        counters: make(map[string]float64),
        gauges:   make(map[string]float64),
        timers:   make(map[string][]float64),
    }, nil
}

// parseMetric parses "metric.name:value|type" (statsd format)
func parseMetric(packet string) (*Metric, error) {
    // Format: name:value|type[@sample_rate]
    colonIdx := strings.LastIndex(packet, ":")
    pipeIdx := strings.LastIndex(packet, "|")
    if colonIdx < 0 || pipeIdx < colonIdx {
        return nil, fmt.Errorf("invalid format: %q", packet)
    }

    name := packet[:colonIdx]
    valueStr := packet[colonIdx+1 : pipeIdx]
    metricType := strings.Split(packet[pipeIdx+1:], "@")[0]

    value, err := strconv.ParseFloat(valueStr, 64)
    if err != nil {
        return nil, fmt.Errorf("invalid value %q", valueStr)
    }

    return &Metric{Name: name, Value: value, Type: metricType}, nil
}

func (sr *StatsReceiver) Receive() {
    buf := make([]byte, 65507) // max UDP payload
    for {
        n, addr, err := sr.conn.ReadFromUDP(buf)
        if err != nil {
            return
        }
        _ = addr // could use for per-IP rate limiting

        // Parse potentially multiple metrics (newline-separated)
        for _, line := range strings.Split(string(buf[:n]), "\n") {
            line = strings.TrimSpace(line)
            if line == "" { continue }

            m, err := parseMetric(line)
            if err != nil {
                sr.dropped++
                continue
            }
            sr.record(m)
            sr.received++
        }
    }
}

func (sr *StatsReceiver) record(m *Metric) {
    sr.mu.Lock()
    defer sr.mu.Unlock()
    switch m.Type {
    case "c":
        sr.counters[m.Name] += m.Value
    case "g":
        sr.gauges[m.Name] = m.Value
    case "ms":
        sr.timers[m.Name] = append(sr.timers[m.Name], m.Value)
    }
}

func (sr *StatsReceiver) Summary() {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    for range ticker.C {
        sr.mu.Lock()
        fmt.Printf("=== Stats (received=%d dropped=%d) ===\n", sr.received, sr.dropped)
        for k, v := range sr.counters {
            fmt.Printf("  counter %s = %.0f\n", k, v)
        }
        for k, v := range sr.gauges {
            fmt.Printf("  gauge   %s = %.2f\n", k, v)
        }
        sr.mu.Unlock()
    }
}

func main() {
    recv, err := NewStatsReceiver(":8125")
    if err != nil {
        fmt.Println("ERROR:", err)
        return
    }
    fmt.Println("statsd receiver on UDP :8125")
    go recv.Summary()
    recv.Receive()
}

Send test metrics:

# Send a counter
echo -n "proxy.requests.total:1|c" | nc -u -w1 127.0.0.1 8125

# Send a gauge
echo -n "proxy.connections.active:42|g" | nc -u -w1 127.0.0.1 8125

# Send a timer
echo -n "proxy.request.latency:45.3|ms" | nc -u -w1 127.0.0.1 8125

Real-World Context

DDoS amplification via UDP: attacker sends 100-byte DNS query with spoofed source IP → DNS server sends 7000-byte response to victim. Your security stack defends against this by rate limiting UDP responses per source IP (Day 98). Understanding the attack vector is why you're building this from scratch.


Generated from week5.md · 8 Oct 2026 16:36