mirror of
https://github.com/juanfont/headscale.git
synced 2025-06-01 01:15:56 +02:00
add traceroute parser
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
parent
ca9d37ed9a
commit
a30afb1121
@ -3,8 +3,12 @@ package util
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"tailscale.com/util/cmpver"
|
"tailscale.com/util/cmpver"
|
||||||
)
|
)
|
||||||
@ -46,3 +50,126 @@ func ParseLoginURLFromCLILogin(output string) (*url.URL, error) {
|
|||||||
|
|
||||||
return loginURL, nil
|
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
|
||||||
|
}
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
)
|
||||||
|
|
||||||
func TestTailscaleVersionNewerOrEqual(t *testing.T) {
|
func TestTailscaleVersionNewerOrEqual(t *testing.T) {
|
||||||
type args struct {
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user