mirror of
https://github.com/juanfont/headscale.git
synced 2025-10-29 03:57:44 +09:00
1291 lines
32 KiB
Go
1291 lines
32 KiB
Go
package util
|
|
|
|
import (
|
|
"errors"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
func TestTailscaleVersionNewerOrEqual(t *testing.T) {
|
|
type args struct {
|
|
minimum string
|
|
toCheck string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want bool
|
|
}{
|
|
{
|
|
name: "is-equal",
|
|
args: args{
|
|
minimum: "1.56",
|
|
toCheck: "1.56",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "is-newer-head",
|
|
args: args{
|
|
minimum: "1.56",
|
|
toCheck: "head",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "is-newer-unstable",
|
|
args: args{
|
|
minimum: "1.56",
|
|
toCheck: "unstable",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "is-newer-patch",
|
|
args: args{
|
|
minimum: "1.56.1",
|
|
toCheck: "1.56.1",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "is-older-patch-same-minor",
|
|
args: args{
|
|
minimum: "1.56.1",
|
|
toCheck: "1.56.0",
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "is-older-unstable",
|
|
args: args{
|
|
minimum: "1.56",
|
|
toCheck: "1.55",
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "is-older-one-stable",
|
|
args: args{
|
|
minimum: "1.56",
|
|
toCheck: "1.54",
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "is-older-five-stable",
|
|
args: args{
|
|
minimum: "1.56",
|
|
toCheck: "1.46",
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "is-older-patch",
|
|
args: args{
|
|
minimum: "1.56",
|
|
toCheck: "1.48.1",
|
|
},
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := TailscaleVersionNewerOrEqual(tt.args.minimum, tt.args.toCheck); got != tt.want {
|
|
t.Errorf("TailscaleVersionNewerThan() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseLoginURLFromCLILogin(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
output string
|
|
wantURL string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "valid https URL",
|
|
output: `
|
|
To authenticate, visit:
|
|
|
|
https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
|
|
|
|
Success.`,
|
|
wantURL: "https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi",
|
|
wantErr: "",
|
|
},
|
|
{
|
|
name: "valid http URL",
|
|
output: `
|
|
To authenticate, visit:
|
|
|
|
http://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
|
|
|
|
Success.`,
|
|
wantURL: "http://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi",
|
|
wantErr: "",
|
|
},
|
|
{
|
|
name: "no URL",
|
|
output: `
|
|
To authenticate, visit:
|
|
|
|
Success.`,
|
|
wantURL: "",
|
|
wantErr: "no URL found",
|
|
},
|
|
{
|
|
name: "multiple URLs",
|
|
output: `
|
|
To authenticate, visit:
|
|
|
|
https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
|
|
|
|
To authenticate, visit:
|
|
|
|
http://headscale.example.com/register/dv1l2k5FackOYl-7-V3mSd_E
|
|
|
|
Success.`,
|
|
wantURL: "",
|
|
wantErr: "multiple URLs found: https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi and http://headscale.example.com/register/dv1l2k5FackOYl-7-V3mSd_E",
|
|
},
|
|
{
|
|
name: "invalid URL",
|
|
output: `
|
|
To authenticate, visit:
|
|
|
|
invalid-url
|
|
|
|
Success.`,
|
|
wantURL: "",
|
|
wantErr: "no URL found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotURL, err := ParseLoginURLFromCLILogin(tt.output)
|
|
if tt.wantErr != "" {
|
|
if err == nil || err.Error() != tt.wantErr {
|
|
t.Errorf("ParseLoginURLFromCLILogin() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("ParseLoginURLFromCLILogin() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if gotURL.String() != tt.wantURL {
|
|
t.Errorf("ParseLoginURLFromCLILogin() = %v, want %v", gotURL, tt.wantURL)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
{
|
|
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 {
|
|
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())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureHostname(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
hostinfo *tailcfg.Hostinfo
|
|
machineKey string
|
|
nodeKey string
|
|
want string
|
|
}{
|
|
{
|
|
name: "valid_hostname",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "test-node",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "test-node",
|
|
},
|
|
{
|
|
name: "nil_hostinfo_with_machine_key",
|
|
hostinfo: nil,
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "node-mkey1234",
|
|
},
|
|
{
|
|
name: "nil_hostinfo_with_node_key_only",
|
|
hostinfo: nil,
|
|
machineKey: "",
|
|
nodeKey: "nkey12345678",
|
|
want: "node-nkey1234",
|
|
},
|
|
{
|
|
name: "nil_hostinfo_no_keys",
|
|
hostinfo: nil,
|
|
machineKey: "",
|
|
nodeKey: "",
|
|
want: "unknown-node",
|
|
},
|
|
{
|
|
name: "empty_hostname_with_machine_key",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "node-mkey1234",
|
|
},
|
|
{
|
|
name: "empty_hostname_with_node_key_only",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "",
|
|
},
|
|
machineKey: "",
|
|
nodeKey: "nkey12345678",
|
|
want: "node-nkey1234",
|
|
},
|
|
{
|
|
name: "empty_hostname_no_keys",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "",
|
|
},
|
|
machineKey: "",
|
|
nodeKey: "",
|
|
want: "unknown-node",
|
|
},
|
|
{
|
|
name: "hostname_exactly_63_chars",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "123456789012345678901234567890123456789012345678901234567890123",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "123456789012345678901234567890123456789012345678901234567890123",
|
|
},
|
|
{
|
|
name: "hostname_64_chars_truncated",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "1234567890123456789012345678901234567890123456789012345678901234",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "hostname_very_long_truncated",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits-of-63-characters-and-should-be-truncated",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "hostname_with_special_chars",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "node-with-special!@#$%",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "hostname_with_unicode",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "node-ñoño-测试",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "short_machine_key",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "",
|
|
},
|
|
machineKey: "short",
|
|
nodeKey: "nkey12345678",
|
|
want: "node-short",
|
|
},
|
|
{
|
|
name: "short_node_key",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "",
|
|
},
|
|
machineKey: "",
|
|
nodeKey: "short",
|
|
want: "node-short",
|
|
},
|
|
{
|
|
name: "hostname_with_emoji_replaced",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "hostname-with-💩",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "hostname_only_emoji_replaced",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "🚀",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "hostname_with_multiple_emojis_replaced",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "node-🎉-🚀-test",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "uppercase_to_lowercase",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "User2-Host",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "user2-host",
|
|
},
|
|
{
|
|
name: "underscore_removed",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "test_node",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "at_sign_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "Test@Host",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "chinese_chars_with_dash_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "server-北京-01",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "chinese_only_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "我的电脑",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "emoji_with_text_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "laptop-🚀",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "mixed_chinese_emoji_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "测试💻机器",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "only_emojis_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "🎉🎊",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "only_at_signs_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "@@@",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "starts_with_dash_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "-test",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "ends_with_dash_invalid",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "test-",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
{
|
|
name: "very_long_hostname_truncated",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: strings.Repeat("t", 70),
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
want: "invalid-",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := EnsureHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
|
|
// For invalid hostnames, we just check the prefix since the random part varies
|
|
if strings.HasPrefix(tt.want, "invalid-") {
|
|
if !strings.HasPrefix(got, "invalid-") {
|
|
t.Errorf("EnsureHostname() = %v, want prefix %v", got, tt.want)
|
|
}
|
|
} else if got != tt.want {
|
|
t.Errorf("EnsureHostname() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureHostnameWithHostinfo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
hostinfo *tailcfg.Hostinfo
|
|
machineKey string
|
|
nodeKey string
|
|
wantHostname string
|
|
checkHostinfo func(*testing.T, *tailcfg.Hostinfo)
|
|
}{
|
|
{
|
|
name: "valid_hostinfo_unchanged",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "test-node",
|
|
OS: "linux",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
wantHostname: "test-node",
|
|
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
|
|
if hi == nil {
|
|
t.Error("hostinfo should not be nil")
|
|
}
|
|
if hi.Hostname != "test-node" {
|
|
t.Errorf("hostname = %v, want test-node", hi.Hostname)
|
|
}
|
|
if hi.OS != "linux" {
|
|
t.Errorf("OS = %v, want linux", hi.OS)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "nil_hostinfo_creates_default",
|
|
hostinfo: nil,
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
wantHostname: "node-mkey1234",
|
|
},
|
|
{
|
|
name: "empty_hostname_updated",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "",
|
|
OS: "darwin",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
wantHostname: "node-mkey1234",
|
|
},
|
|
{
|
|
name: "long_hostname_rejected",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits-of-63-characters",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
wantHostname: "invalid-",
|
|
},
|
|
{
|
|
name: "nil_hostinfo_node_key_only",
|
|
hostinfo: nil,
|
|
machineKey: "",
|
|
nodeKey: "nkey12345678",
|
|
wantHostname: "node-nkey1234",
|
|
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
|
|
if hi == nil {
|
|
t.Error("hostinfo should not be nil")
|
|
}
|
|
if hi.Hostname != "node-nkey1234" {
|
|
t.Errorf("hostname = %v, want node-nkey1234", hi.Hostname)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "nil_hostinfo_no_keys",
|
|
hostinfo: nil,
|
|
machineKey: "",
|
|
nodeKey: "",
|
|
wantHostname: "unknown-node",
|
|
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
|
|
if hi == nil {
|
|
t.Error("hostinfo should not be nil")
|
|
}
|
|
if hi.Hostname != "unknown-node" {
|
|
t.Errorf("hostname = %v, want unknown-node", hi.Hostname)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "empty_hostname_no_keys",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "",
|
|
},
|
|
machineKey: "",
|
|
nodeKey: "",
|
|
wantHostname: "unknown-node",
|
|
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
|
|
if hi == nil {
|
|
t.Error("hostinfo should not be nil")
|
|
}
|
|
if hi.Hostname != "unknown-node" {
|
|
t.Errorf("hostname = %v, want unknown-node", hi.Hostname)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "preserves_other_fields",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "test",
|
|
OS: "windows",
|
|
OSVersion: "10.0.19044",
|
|
DeviceModel: "test-device",
|
|
BackendLogID: "log123",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
wantHostname: "test",
|
|
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
|
|
if hi == nil {
|
|
t.Error("hostinfo should not be nil")
|
|
}
|
|
if hi.Hostname != "test" {
|
|
t.Errorf("hostname = %v, want test", hi.Hostname)
|
|
}
|
|
if hi.OS != "windows" {
|
|
t.Errorf("OS = %v, want windows", hi.OS)
|
|
}
|
|
if hi.OSVersion != "10.0.19044" {
|
|
t.Errorf("OSVersion = %v, want 10.0.19044", hi.OSVersion)
|
|
}
|
|
if hi.DeviceModel != "test-device" {
|
|
t.Errorf("DeviceModel = %v, want test-device", hi.DeviceModel)
|
|
}
|
|
if hi.BackendLogID != "log123" {
|
|
t.Errorf("BackendLogID = %v, want log123", hi.BackendLogID)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "exactly_63_chars_unchanged",
|
|
hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "123456789012345678901234567890123456789012345678901234567890123",
|
|
},
|
|
machineKey: "mkey12345678",
|
|
nodeKey: "nkey12345678",
|
|
wantHostname: "123456789012345678901234567890123456789012345678901234567890123",
|
|
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
|
|
if hi == nil {
|
|
t.Error("hostinfo should not be nil")
|
|
}
|
|
if len(hi.Hostname) != 63 {
|
|
t.Errorf("hostname length = %v, want 63", len(hi.Hostname))
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
gotHostname := EnsureHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
|
|
// For invalid hostnames, we just check the prefix since the random part varies
|
|
if strings.HasPrefix(tt.wantHostname, "invalid-") {
|
|
if !strings.HasPrefix(gotHostname, "invalid-") {
|
|
t.Errorf("EnsureHostname() = %v, want prefix %v", gotHostname, tt.wantHostname)
|
|
}
|
|
} else if gotHostname != tt.wantHostname {
|
|
t.Errorf("EnsureHostname() hostname = %v, want %v", gotHostname, tt.wantHostname)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureHostname_DNSLabelLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []string{
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
|
}
|
|
|
|
for i, hostname := range testCases {
|
|
t.Run(cmp.Diff("", ""), func(t *testing.T) {
|
|
hostinfo := &tailcfg.Hostinfo{Hostname: hostname}
|
|
result := EnsureHostname(hostinfo, "mkey", "nkey")
|
|
if len(result) > 63 {
|
|
t.Errorf("test case %d: hostname length = %d, want <= 63", i, len(result))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureHostname_Idempotent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
originalHostinfo := &tailcfg.Hostinfo{
|
|
Hostname: "test-node",
|
|
OS: "linux",
|
|
}
|
|
|
|
hostname1 := EnsureHostname(originalHostinfo, "mkey", "nkey")
|
|
hostname2 := EnsureHostname(originalHostinfo, "mkey", "nkey")
|
|
|
|
if hostname1 != hostname2 {
|
|
t.Errorf("hostnames not equal: %v != %v", hostname1, hostname2)
|
|
}
|
|
}
|