diff --git a/hscontrol/util/util.go b/hscontrol/util/util.go index 97bb3da4..f3843f81 100644 --- a/hscontrol/util/util.go +++ b/hscontrol/util/util.go @@ -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(`( 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))) } } diff --git a/hscontrol/util/util_test.go b/hscontrol/util/util_test.go index b1a18610..47a2709b 100644 --- a/hscontrol/util/util_test.go +++ b/hscontrol/util/util_test.go @@ -335,6 +335,431 @@ func TestParseTraceroute(t *testing.T) { want: Traceroute{}, wantErr: true, }, + { + name: "windows tracert format", + input: `Tracing route to google.com [8.8.8.8] +over a maximum of 30 hops: + + 1 <1 ms <1 ms <1 ms router.local [192.168.1.1] + 2 5 ms 4 ms 5 ms 10.0.0.1 + 3 * * * Request timed out. + 4 20 ms 19 ms 21 ms 8.8.8.8`, + want: Traceroute{ + Hostname: "google.com", + IP: netip.MustParseAddr("8.8.8.8"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "router.local", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 1 * time.Millisecond, + 1 * time.Millisecond, + 1 * time.Millisecond, + }, + }, + { + Hop: 2, + Hostname: "10.0.0.1", + IP: netip.MustParseAddr("10.0.0.1"), + Latencies: []time.Duration{ + 5 * time.Millisecond, + 4 * time.Millisecond, + 5 * time.Millisecond, + }, + }, + { + Hop: 3, + Hostname: "*", + }, + { + Hop: 4, + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Latencies: []time.Duration{ + 20 * time.Millisecond, + 19 * time.Millisecond, + 21 * time.Millisecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "mixed latency formats", + input: `traceroute to 192.168.1.1 (192.168.1.1), 30 hops max, 60 byte packets + 1 gateway (192.168.1.1) 0.5 ms * 0.4 ms`, + want: Traceroute{ + Hostname: "192.168.1.1", + IP: netip.MustParseAddr("192.168.1.1"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "gateway", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 500 * time.Microsecond, + 400 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "only one latency value", + input: `traceroute to 10.0.0.1 (10.0.0.1), 30 hops max, 60 byte packets + 1 10.0.0.1 (10.0.0.1) 1.5 ms`, + want: Traceroute{ + Hostname: "10.0.0.1", + IP: netip.MustParseAddr("10.0.0.1"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "10.0.0.1", + IP: netip.MustParseAddr("10.0.0.1"), + Latencies: []time.Duration{ + 1500 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "backward compatibility - original format with 3 latencies", + input: `traceroute to 172.24.0.3 (172.24.0.3), 30 hops max, 46 byte packets + 1 ts-head-hk0urr.headscale.net (100.64.0.1) 1.135 ms 0.922 ms 0.619 ms + 2 172.24.0.3 (172.24.0.3) 0.593 ms 0.549 ms 0.522 ms`, + want: Traceroute{ + Hostname: "172.24.0.3", + IP: netip.MustParseAddr("172.24.0.3"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "ts-head-hk0urr.headscale.net", + IP: netip.MustParseAddr("100.64.0.1"), + Latencies: []time.Duration{ + 1135 * time.Microsecond, + 922 * time.Microsecond, + 619 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "172.24.0.3", + IP: netip.MustParseAddr("172.24.0.3"), + Latencies: []time.Duration{ + 593 * time.Microsecond, + 549 * time.Microsecond, + 522 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "two latencies only - common on packet loss", + input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + 1 gateway (192.168.1.1) 1.2 ms 1.1 ms`, + want: Traceroute{ + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "gateway", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 1200 * time.Microsecond, + 1100 * time.Microsecond, + }, + }, + }, + Success: false, + Err: errors.New("traceroute did not reach target"), + }, + wantErr: false, + }, + { + name: "hostname without parentheses - some traceroute versions", + input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + 1 192.168.1.1 1.2 ms 1.1 ms 1.0 ms + 2 8.8.8.8 20.1 ms 19.9 ms 20.2 ms`, + want: Traceroute{ + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "192.168.1.1", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 1200 * time.Microsecond, + 1100 * time.Microsecond, + 1000 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Latencies: []time.Duration{ + 20100 * time.Microsecond, + 19900 * time.Microsecond, + 20200 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "ipv6 traceroute", + input: `traceroute to 2001:4860:4860::8888 (2001:4860:4860::8888), 30 hops max, 80 byte packets + 1 2001:db8::1 (2001:db8::1) 1.123 ms 1.045 ms 0.987 ms + 2 2001:4860:4860::8888 (2001:4860:4860::8888) 15.234 ms 14.876 ms 15.123 ms`, + want: Traceroute{ + Hostname: "2001:4860:4860::8888", + IP: netip.MustParseAddr("2001:4860:4860::8888"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "2001:db8::1", + IP: netip.MustParseAddr("2001:db8::1"), + Latencies: []time.Duration{ + 1123 * time.Microsecond, + 1045 * time.Microsecond, + 987 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "2001:4860:4860::8888", + IP: netip.MustParseAddr("2001:4860:4860::8888"), + Latencies: []time.Duration{ + 15234 * time.Microsecond, + 14876 * time.Microsecond, + 15123 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "macos traceroute with extra spacing", + input: `traceroute to google.com (8.8.8.8), 64 hops max, 52 byte packets + 1 router.home (192.168.1.1) 2.345 ms 1.234 ms 1.567 ms + 2 * * * + 3 isp-gw.net (10.1.1.1) 15.234 ms 14.567 ms 15.890 ms + 4 google.com (8.8.8.8) 20.123 ms 19.456 ms 20.789 ms`, + want: Traceroute{ + Hostname: "google.com", + IP: netip.MustParseAddr("8.8.8.8"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "router.home", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 2345 * time.Microsecond, + 1234 * time.Microsecond, + 1567 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "*", + }, + { + Hop: 3, + Hostname: "isp-gw.net", + IP: netip.MustParseAddr("10.1.1.1"), + Latencies: []time.Duration{ + 15234 * time.Microsecond, + 14567 * time.Microsecond, + 15890 * time.Microsecond, + }, + }, + { + Hop: 4, + Hostname: "google.com", + IP: netip.MustParseAddr("8.8.8.8"), + Latencies: []time.Duration{ + 20123 * time.Microsecond, + 19456 * time.Microsecond, + 20789 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "busybox traceroute minimal format", + input: `traceroute to 10.0.0.1 (10.0.0.1), 30 hops max, 38 byte packets + 1 10.0.0.1 (10.0.0.1) 1.234 ms 1.123 ms 1.456 ms`, + want: Traceroute{ + Hostname: "10.0.0.1", + IP: netip.MustParseAddr("10.0.0.1"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "10.0.0.1", + IP: netip.MustParseAddr("10.0.0.1"), + Latencies: []time.Duration{ + 1234 * time.Microsecond, + 1123 * time.Microsecond, + 1456 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "linux traceroute with dns failure fallback to IP", + input: `traceroute to example.com (93.184.216.34), 30 hops max, 60 byte packets + 1 192.168.1.1 (192.168.1.1) 1.234 ms 1.123 ms 1.098 ms + 2 10.0.0.1 (10.0.0.1) 5.678 ms 5.432 ms 5.321 ms + 3 93.184.216.34 (93.184.216.34) 20.123 ms 19.876 ms 20.234 ms`, + want: Traceroute{ + Hostname: "example.com", + IP: netip.MustParseAddr("93.184.216.34"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "192.168.1.1", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 1234 * time.Microsecond, + 1123 * time.Microsecond, + 1098 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "10.0.0.1", + IP: netip.MustParseAddr("10.0.0.1"), + Latencies: []time.Duration{ + 5678 * time.Microsecond, + 5432 * time.Microsecond, + 5321 * time.Microsecond, + }, + }, + { + Hop: 3, + Hostname: "93.184.216.34", + IP: netip.MustParseAddr("93.184.216.34"), + Latencies: []time.Duration{ + 20123 * time.Microsecond, + 19876 * time.Microsecond, + 20234 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "alpine linux traceroute with ms variations", + input: `traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 46 byte packets + 1 gateway (192.168.0.1) 0.456ms 0.389ms 0.412ms + 2 1.1.1.1 (1.1.1.1) 8.234ms 7.987ms 8.123ms`, + want: Traceroute{ + Hostname: "1.1.1.1", + IP: netip.MustParseAddr("1.1.1.1"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "gateway", + IP: netip.MustParseAddr("192.168.0.1"), + Latencies: []time.Duration{ + 456 * time.Microsecond, + 389 * time.Microsecond, + 412 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "1.1.1.1", + IP: netip.MustParseAddr("1.1.1.1"), + Latencies: []time.Duration{ + 8234 * time.Microsecond, + 7987 * time.Microsecond, + 8123 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "mixed asterisk and latency values", + input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + 1 gateway (192.168.1.1) * 1.234 ms 1.123 ms + 2 10.0.0.1 (10.0.0.1) 5.678 ms * 5.432 ms + 3 8.8.8.8 (8.8.8.8) 20.123 ms 19.876 ms *`, + want: Traceroute{ + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "gateway", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 1234 * time.Microsecond, + 1123 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "10.0.0.1", + IP: netip.MustParseAddr("10.0.0.1"), + Latencies: []time.Duration{ + 5678 * time.Microsecond, + 5432 * time.Microsecond, + }, + }, + { + Hop: 3, + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Latencies: []time.Duration{ + 20123 * time.Microsecond, + 19876 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, } for _, tt := range tests {