util: harden parsing of traceroute

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby
2025-08-06 08:44:59 +02:00
committed by Kristoffer Dalby
parent 50ed24847b
commit 81b3e8f743
2 changed files with 520 additions and 23 deletions

View File

@@ -90,15 +90,19 @@ func ParseTraceroute(output string) (Traceroute, error) {
return Traceroute{}, errors.New("empty traceroute output")
}
// Parse the header line
headerRegex := regexp.MustCompile(`traceroute to ([^ ]+) \(([^)]+)\)`)
// Parse the header line - handle both 'traceroute' and 'tracert' (Windows)
headerRegex := regexp.MustCompile(`(?i)(?:traceroute|tracing route) to ([^ ]+) (?:\[([^\]]+)\]|\(([^)]+)\))`)
headerMatches := headerRegex.FindStringSubmatch(lines[0])
if len(headerMatches) != 3 {
if len(headerMatches) < 2 {
return Traceroute{}, fmt.Errorf("parsing traceroute header: %s", lines[0])
}
hostname := headerMatches[1]
// IP can be in either capture group 2 or 3 depending on format
ipStr := headerMatches[2]
if ipStr == "" {
ipStr = headerMatches[3]
}
ip, err := netip.ParseAddr(ipStr)
if err != nil {
return Traceroute{}, fmt.Errorf("parsing IP address %s: %w", ipStr, err)
@@ -111,44 +115,112 @@ func ParseTraceroute(output string) (Traceroute, error) {
Success: false,
}
// Parse each hop line
hopRegex := regexp.MustCompile(`^\s*(\d+)\s+(?:([^ ]+) \(([^)]+)\)|(\*))(?:\s+(\d+\.\d+) ms)?(?:\s+(\d+\.\d+) ms)?(?:\s+(\d+\.\d+) ms)?`)
// More flexible regex that handles various traceroute output formats
// Main pattern handles: "hostname (IP)", "hostname [IP]", "IP only", "* * *"
hopRegex := regexp.MustCompile(`^\s*(\d+)\s+(.*)$`)
// Patterns for parsing the hop details
hostIPRegex := regexp.MustCompile(`^([^ ]+) \(([^)]+)\)`)
hostIPBracketRegex := regexp.MustCompile(`^([^ ]+) \[([^\]]+)\]`)
// Pattern for latencies with flexible spacing and optional '<'
latencyRegex := regexp.MustCompile(`(<?\d+(?:\.\d+)?)\s*ms\b`)
for i := 1; i < len(lines); i++ {
matches := hopRegex.FindStringSubmatch(lines[i])
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
matches := hopRegex.FindStringSubmatch(line)
if len(matches) == 0 {
continue
}
hop, err := strconv.Atoi(matches[1])
if err != nil {
return Traceroute{}, fmt.Errorf("parsing hop number: %w", err)
// Skip lines that don't start with a hop number
continue
}
remainder := strings.TrimSpace(matches[2])
var hopHostname string
var hopIP netip.Addr
var latencies []time.Duration
// Handle hostname and IP
if matches[2] != "" && matches[3] != "" {
hopHostname = matches[2]
hopIP, err = netip.ParseAddr(matches[3])
if err != nil {
return Traceroute{}, fmt.Errorf("parsing hop IP address %s: %w", matches[3], err)
// Check for Windows tracert format which has latencies before hostname
// Format: " 1 <1 ms <1 ms <1 ms router.local [192.168.1.1]"
latencyFirst := false
if strings.Contains(remainder, " ms ") && !strings.HasPrefix(remainder, "*") {
// Check if latencies appear before any hostname/IP
firstSpace := strings.Index(remainder, " ")
if firstSpace > 0 {
firstPart := remainder[:firstSpace]
if _, err := strconv.ParseFloat(strings.TrimPrefix(firstPart, "<"), 64); err == nil {
latencyFirst = true
}
}
} else if matches[4] == "*" {
hopHostname = "*"
// No IP for timeouts
}
// Parse latencies
for j := 5; j <= 7; j++ {
if j < len(matches) && matches[j] != "" {
ms, err := strconv.ParseFloat(matches[j], 64)
if err != nil {
return Traceroute{}, fmt.Errorf("parsing latency: %w", err)
if latencyFirst {
// Windows format: extract latencies first
for {
latMatch := latencyRegex.FindStringSubmatchIndex(remainder)
if latMatch == nil || latMatch[0] > 0 {
break
}
// Extract and remove the latency from the beginning
latStr := strings.TrimPrefix(remainder[latMatch[2]:latMatch[3]], "<")
ms, err := strconv.ParseFloat(latStr, 64)
if err == nil {
// Round to nearest microsecond to avoid floating point precision issues
duration := time.Duration(ms * float64(time.Millisecond))
latencies = append(latencies, duration.Round(time.Microsecond))
}
remainder = strings.TrimSpace(remainder[latMatch[1]:])
}
}
// Now parse hostname/IP from remainder
if strings.HasPrefix(remainder, "*") {
// Timeout hop
hopHostname = "*"
// Skip any remaining asterisks
remainder = strings.TrimLeft(remainder, "* ")
} else if hostMatch := hostIPRegex.FindStringSubmatch(remainder); len(hostMatch) >= 3 {
// Format: hostname (IP)
hopHostname = hostMatch[1]
hopIP, _ = netip.ParseAddr(hostMatch[2])
remainder = strings.TrimSpace(remainder[len(hostMatch[0]):])
} else if hostMatch := hostIPBracketRegex.FindStringSubmatch(remainder); len(hostMatch) >= 3 {
// Format: hostname [IP] (Windows)
hopHostname = hostMatch[1]
hopIP, _ = netip.ParseAddr(hostMatch[2])
remainder = strings.TrimSpace(remainder[len(hostMatch[0]):])
} else {
// Try to parse as IP only or hostname only
parts := strings.Fields(remainder)
if len(parts) > 0 {
hopHostname = parts[0]
if ip, err := netip.ParseAddr(parts[0]); err == nil {
hopIP = ip
}
remainder = strings.TrimSpace(strings.Join(parts[1:], " "))
}
}
// Extract latencies from the remaining part (if not already done)
if !latencyFirst {
latencyMatches := latencyRegex.FindAllStringSubmatch(remainder, -1)
for _, match := range latencyMatches {
if len(match) > 1 {
// Remove '<' prefix if present (e.g., "<1 ms")
latStr := strings.TrimPrefix(match[1], "<")
ms, err := strconv.ParseFloat(latStr, 64)
if err == nil {
// Round to nearest microsecond to avoid floating point precision issues
duration := time.Duration(ms * float64(time.Millisecond))
latencies = append(latencies, duration.Round(time.Microsecond))
}
}
latencies = append(latencies, time.Duration(ms*float64(time.Millisecond)))
}
}