diff --git a/hscontrol/util/util.go b/hscontrol/util/util.go index 569af354..a41ee6f8 100644 --- a/hscontrol/util/util.go +++ b/hscontrol/util/util.go @@ -3,8 +3,12 @@ package util import ( "errors" "fmt" + "net/netip" "net/url" + "regexp" + "strconv" "strings" + "time" "tailscale.com/util/cmpver" ) @@ -46,3 +50,126 @@ func ParseLoginURLFromCLILogin(output string) (*url.URL, error) { return loginURL, nil } + +type TraceroutePath struct { + // Hop is the current jump in the total traceroute. + Hop int + + // Hostname is the resolved hostname or IP address identifying the jump + Hostname string + + // IP is the IP address of the jump + IP netip.Addr + + // Latencies is a list of the latencies for this jump + Latencies []time.Duration +} + +type Traceroute struct { + // Hostname is the resolved hostname or IP address identifying the target + Hostname string + + // IP is the IP address of the target + IP netip.Addr + + // Route is the path taken to reach the target if successful. The list is ordered by the path taken. + Route []TraceroutePath + + // Success indicates if the traceroute was successful. + Success bool + + // Err contains an error if the traceroute was not successful. + Err error +} + +// ParseTraceroute parses the output of the traceroute command and returns a Traceroute struct +func ParseTraceroute(output string) (Traceroute, error) { + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 1 { + return Traceroute{}, errors.New("empty traceroute output") + } + + // Parse the header line + headerRegex := regexp.MustCompile(`traceroute to ([^ ]+) \(([^)]+)\)`) + headerMatches := headerRegex.FindStringSubmatch(lines[0]) + if len(headerMatches) != 3 { + return Traceroute{}, fmt.Errorf("parsing traceroute header: %s", lines[0]) + } + + hostname := headerMatches[1] + ipStr := headerMatches[2] + ip, err := netip.ParseAddr(ipStr) + if err != nil { + return Traceroute{}, fmt.Errorf("parsing IP address %s: %w", ipStr, err) + } + + result := Traceroute{ + Hostname: hostname, + IP: ip, + Route: []TraceroutePath{}, + Success: false, + } + + // Parse each hop line + hopRegex := regexp.MustCompile(`^\s*(\d+)\s+(?:([^ ]+) \(([^)]+)\)|(\*))(?:\s+(\d+\.\d+) ms)?(?:\s+(\d+\.\d+) ms)?(?:\s+(\d+\.\d+) ms)?`) + + for i := 1; i < len(lines); i++ { + matches := hopRegex.FindStringSubmatch(lines[i]) + if len(matches) == 0 { + continue + } + + hop, err := strconv.Atoi(matches[1]) + if err != nil { + return Traceroute{}, fmt.Errorf("parsing hop number: %w", err) + } + + 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) + } + } else if matches[4] == "*" { + hopHostname = "*" + // No IP for timeouts + } + + // Parse latencies + for j := 5; j <= 7; j++ { + if matches[j] != "" { + ms, err := strconv.ParseFloat(matches[j], 64) + if err != nil { + return Traceroute{}, fmt.Errorf("parsing latency: %w", err) + } + latencies = append(latencies, time.Duration(ms*float64(time.Millisecond))) + } + } + + path := TraceroutePath{ + Hop: hop, + Hostname: hopHostname, + IP: hopIP, + Latencies: latencies, + } + + result.Route = append(result.Route, path) + + // Check if we've reached the target + if hopIP == ip { + result.Success = true + } + } + + // If we didn't reach the target, it's unsuccessful + if !result.Success { + result.Err = errors.New("traceroute did not reach target") + } + + return result, nil +} diff --git a/hscontrol/util/util_test.go b/hscontrol/util/util_test.go index 1e331fe2..b1a18610 100644 --- a/hscontrol/util/util_test.go +++ b/hscontrol/util/util_test.go @@ -1,6 +1,13 @@ package util -import "testing" +import ( + "errors" + "net/netip" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) func TestTailscaleVersionNewerOrEqual(t *testing.T) { type args struct { @@ -178,3 +185,186 @@ Success.`, }) } } + +func TestParseTraceroute(t *testing.T) { + tests := []struct { + name string + input string + want Traceroute + wantErr bool + }{ + { + name: "simple successful traceroute", + 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: "traceroute with timeouts", + input: `traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + 1 router.local (192.168.1.1) 1.234 ms 1.123 ms 1.121 ms + 2 * * * + 3 isp-gateway.net (10.0.0.1) 15.678 ms 14.789 ms 15.432 ms + 4 8.8.8.8 (8.8.8.8) 20.123 ms 19.876 ms 20.345 ms`, + want: Traceroute{ + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "router.local", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 1234 * time.Microsecond, + 1123 * time.Microsecond, + 1121 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "*", + }, + { + Hop: 3, + Hostname: "isp-gateway.net", + IP: netip.MustParseAddr("10.0.0.1"), + Latencies: []time.Duration{ + 15678 * time.Microsecond, + 14789 * time.Microsecond, + 15432 * time.Microsecond, + }, + }, + { + Hop: 4, + Hostname: "8.8.8.8", + IP: netip.MustParseAddr("8.8.8.8"), + Latencies: []time.Duration{ + 20123 * time.Microsecond, + 19876 * time.Microsecond, + 20345 * time.Microsecond, + }, + }, + }, + Success: true, + Err: nil, + }, + wantErr: false, + }, + { + name: "unsuccessful traceroute", + input: `traceroute to 10.0.0.99 (10.0.0.99), 5 hops max, 60 byte packets + 1 router.local (192.168.1.1) 1.234 ms 1.123 ms 1.121 ms + 2 * * * + 3 * * * + 4 * * * + 5 * * *`, + want: Traceroute{ + Hostname: "10.0.0.99", + IP: netip.MustParseAddr("10.0.0.99"), + Route: []TraceroutePath{ + { + Hop: 1, + Hostname: "router.local", + IP: netip.MustParseAddr("192.168.1.1"), + Latencies: []time.Duration{ + 1234 * time.Microsecond, + 1123 * time.Microsecond, + 1121 * time.Microsecond, + }, + }, + { + Hop: 2, + Hostname: "*", + }, + { + Hop: 3, + Hostname: "*", + }, + { + Hop: 4, + Hostname: "*", + }, + { + Hop: 5, + Hostname: "*", + }, + }, + Success: false, + Err: errors.New("traceroute did not reach target"), + }, + wantErr: false, + }, + { + name: "empty input", + input: "", + want: Traceroute{}, + wantErr: true, + }, + { + name: "invalid header", + input: "not a valid traceroute output", + want: Traceroute{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTraceroute(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTraceroute() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + // Special handling for error field since it can't be directly compared with cmp.Diff + gotErr := got.Err + wantErr := tt.want.Err + got.Err = nil + tt.want.Err = nil + + if diff := cmp.Diff(tt.want, got, IPComparer); diff != "" { + t.Errorf("ParseTraceroute() mismatch (-want +got):\n%s", diff) + } + + // Now check error field separately + if (gotErr == nil) != (wantErr == nil) { + t.Errorf("Error field: got %v, want %v", gotErr, wantErr) + } else if gotErr != nil && wantErr != nil && gotErr.Error() != wantErr.Error() { + t.Errorf("Error message: got %q, want %q", gotErr.Error(), wantErr.Error()) + } + }) + } +}