Skip to content

Commit 28796c6

Browse files
committed
TUN-8729: implement network collection for diagnostic procedure
## Summary This PR adds implementation for windows & unix that collect the tracert.exe & traceroute output in the form of hops. Closes TUN-8729
1 parent 9da15b5 commit 28796c6

File tree

7 files changed

+555
-2
lines changed

7 files changed

+555
-2
lines changed

diagnostic/error.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,5 @@ var (
1616
// Error used when given key is not found while parsing KV.
1717
ErrKeyNotFound = errors.New("key not found")
1818
// Error used when there is no disk volume information available.
19-
ErrNoVolumeFound = errors.New("no disk volume information found")
20-
ErrNoPathAvailable = errors.New("no path available")
19+
ErrNoVolumeFound = errors.New("no disk volume information found")
2120
)

diagnostic/network/collector.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package diagnostic
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
const MicrosecondsFactor = 1000.0
9+
10+
// For now only support ICMP is provided.
11+
type IPVersion int
12+
13+
const (
14+
V4 IPVersion = iota
15+
V6 IPVersion = iota
16+
)
17+
18+
type Hop struct {
19+
Hop uint8 `json:"hop,omitempty"` // hop number along the route
20+
Domain string `json:"domain,omitempty"` // domain and/or ip of the hop, this field will be '*' if the hop is a timeout
21+
Rtts []time.Duration `json:"rtts,omitempty"` // RTT measurements in microseconds
22+
}
23+
24+
type TraceOptions struct {
25+
ttl uint64 // number of hops to perform
26+
timeout time.Duration // wait timeout for each response
27+
address string // address to trace
28+
useV4 bool
29+
}
30+
31+
func NewTimeoutHop(
32+
hop uint8,
33+
) *Hop {
34+
// Whenever there is a hop in the format of 'N * * *'
35+
// it means that the hop in the path didn't answer to
36+
// any probe.
37+
return NewHop(
38+
hop,
39+
"*",
40+
nil,
41+
)
42+
}
43+
44+
func NewHop(hop uint8, domain string, rtts []time.Duration) *Hop {
45+
return &Hop{
46+
hop,
47+
domain,
48+
rtts,
49+
}
50+
}
51+
52+
func NewTraceOptions(
53+
ttl uint64,
54+
timeout time.Duration,
55+
address string,
56+
useV4 bool,
57+
) TraceOptions {
58+
return TraceOptions{
59+
ttl,
60+
timeout,
61+
address,
62+
useV4,
63+
}
64+
}
65+
66+
type NetworkCollector interface {
67+
// Performs a trace route operation with the specified options.
68+
// In case the trace fails, it will return a non-nil error and
69+
// it may return a string which represents the raw information
70+
// obtained.
71+
// In case it is successful it will only return an array of Hops
72+
// an empty string and a nil error.
73+
Collect(ctx context.Context, options TraceOptions) ([]*Hop, string, error)
74+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//go:build darwin || linux
2+
3+
package diagnostic
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"os/exec"
9+
"strconv"
10+
"strings"
11+
"time"
12+
)
13+
14+
type NetworkCollectorImpl struct{}
15+
16+
func (tracer *NetworkCollectorImpl) Collect(ctx context.Context, options TraceOptions) ([]*Hop, string, error) {
17+
args := []string{
18+
"-I",
19+
"-w",
20+
strconv.FormatInt(int64(options.timeout.Seconds()), 10),
21+
"-m",
22+
strconv.FormatUint(options.ttl, 10),
23+
options.address,
24+
}
25+
26+
var command string
27+
28+
switch options.useV4 {
29+
case false:
30+
command = "traceroute6"
31+
default:
32+
command = "traceroute"
33+
}
34+
35+
process := exec.CommandContext(ctx, command, args...)
36+
37+
return decodeNetworkOutputToFile(process, DecodeLine)
38+
}
39+
40+
func DecodeLine(text string) (*Hop, error) {
41+
fields := strings.Fields(text)
42+
parts := []string{}
43+
filter := func(s string) bool { return s != "*" && s != "ms" }
44+
45+
for _, field := range fields {
46+
if filter(field) {
47+
parts = append(parts, field)
48+
}
49+
}
50+
51+
index, err := strconv.ParseUint(parts[0], 10, 8)
52+
if err != nil {
53+
return nil, fmt.Errorf("couldn't parse index from timeout hop: %w", err)
54+
}
55+
56+
if len(parts) == 1 {
57+
return NewTimeoutHop(uint8(index)), nil
58+
}
59+
60+
domain := ""
61+
rtts := []time.Duration{}
62+
63+
for _, part := range parts[1:] {
64+
rtt, err := strconv.ParseFloat(part, 64)
65+
if err != nil {
66+
domain += part + " "
67+
} else {
68+
rtts = append(rtts, time.Duration(rtt*MicrosecondsFactor))
69+
}
70+
}
71+
domain, _ = strings.CutSuffix(domain, " ")
72+
73+
return NewHop(uint8(index), domain, rtts), nil
74+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//go:build darwin || linux
2+
3+
package diagnostic_test
4+
5+
import (
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
diagnostic "github.com/cloudflare/cloudflared/diagnostic/network"
14+
)
15+
16+
func TestDecode(t *testing.T) {
17+
t.Parallel()
18+
19+
tests := []struct {
20+
name string
21+
text string
22+
expectedHops []*diagnostic.Hop
23+
expectErr bool
24+
}{
25+
{
26+
"repeated hop index parse failure",
27+
`1 172.68.101.121 (172.68.101.121) 12.874 ms 15.517 ms 15.311 ms
28+
2 172.68.101.121 (172.68.101.121) 12.874 ms 15.517 ms 15.311 ms
29+
someletters * * *`,
30+
nil,
31+
true,
32+
},
33+
{
34+
"hop index parse failure",
35+
`1 172.68.101.121 (172.68.101.121) 12.874 ms 15.517 ms 15.311 ms
36+
2 172.68.101.121 (172.68.101.121) 12.874 ms 15.517 ms 15.311 ms
37+
someletters 8.8.8.8 8.8.8.9 abc ms 0.456 ms 0.789 ms`,
38+
nil,
39+
true,
40+
},
41+
{
42+
"missing rtt",
43+
`1 172.68.101.121 (172.68.101.121) 12.874 ms 15.517 ms 15.311 ms
44+
2 * 8.8.8.8 8.8.8.9 0.456 ms 0.789 ms`,
45+
[]*diagnostic.Hop{
46+
diagnostic.NewHop(
47+
uint8(1),
48+
"172.68.101.121 (172.68.101.121)",
49+
[]time.Duration{
50+
time.Duration(12874),
51+
time.Duration(15517),
52+
time.Duration(15311),
53+
},
54+
),
55+
diagnostic.NewHop(
56+
uint8(2),
57+
"8.8.8.8 8.8.8.9",
58+
[]time.Duration{
59+
time.Duration(456),
60+
time.Duration(789),
61+
},
62+
),
63+
},
64+
false,
65+
},
66+
{
67+
"simple example ipv4",
68+
`1 172.68.101.121 (172.68.101.121) 12.874 ms 15.517 ms 15.311 ms
69+
2 172.68.101.121 (172.68.101.121) 12.874 ms 15.517 ms 15.311 ms
70+
3 * * *`,
71+
[]*diagnostic.Hop{
72+
diagnostic.NewHop(
73+
uint8(1),
74+
"172.68.101.121 (172.68.101.121)",
75+
[]time.Duration{
76+
time.Duration(12874),
77+
time.Duration(15517),
78+
time.Duration(15311),
79+
},
80+
),
81+
diagnostic.NewHop(
82+
uint8(2),
83+
"172.68.101.121 (172.68.101.121)",
84+
[]time.Duration{
85+
time.Duration(12874),
86+
time.Duration(15517),
87+
time.Duration(15311),
88+
},
89+
),
90+
diagnostic.NewTimeoutHop(uint8(3)),
91+
},
92+
false,
93+
},
94+
{
95+
"simple example ipv6",
96+
` 1 2400:cb00:107:1024::ac44:6550 12.780 ms 9.118 ms 10.046 ms
97+
2 2a09:bac1:: 9.945 ms 10.033 ms 11.562 ms`,
98+
[]*diagnostic.Hop{
99+
diagnostic.NewHop(
100+
uint8(1),
101+
"2400:cb00:107:1024::ac44:6550",
102+
[]time.Duration{
103+
time.Duration(12780),
104+
time.Duration(9118),
105+
time.Duration(10046),
106+
},
107+
),
108+
diagnostic.NewHop(
109+
uint8(2),
110+
"2a09:bac1::",
111+
[]time.Duration{
112+
time.Duration(9945),
113+
time.Duration(10033),
114+
time.Duration(11562),
115+
},
116+
),
117+
},
118+
false,
119+
},
120+
}
121+
122+
for _, test := range tests {
123+
t.Run(test.name, func(t *testing.T) {
124+
t.Parallel()
125+
126+
hops, err := diagnostic.Decode(strings.NewReader(test.text), diagnostic.DecodeLine)
127+
if test.expectErr {
128+
require.Error(t, err)
129+
} else {
130+
require.NoError(t, err)
131+
assert.Equal(t, test.expectedHops, hops)
132+
}
133+
})
134+
}
135+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package diagnostic
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"fmt"
7+
"io"
8+
"os/exec"
9+
)
10+
11+
type DecodeLineFunc func(text string) (*Hop, error)
12+
13+
func decodeNetworkOutputToFile(command *exec.Cmd, fn DecodeLineFunc) ([]*Hop, string, error) {
14+
stdout, err := command.StdoutPipe()
15+
if err != nil {
16+
return nil, "", fmt.Errorf("error piping traceroute's output: %w", err)
17+
}
18+
19+
if err := command.Start(); err != nil {
20+
return nil, "", fmt.Errorf("error starting traceroute: %w", err)
21+
}
22+
23+
// Tee the output to a string to have the raw information
24+
// in case the decode call fails
25+
// This error is handled only after the Wait call below returns
26+
// otherwise the process can become a zombie
27+
buf := bytes.NewBuffer([]byte{})
28+
tee := io.TeeReader(stdout, buf)
29+
hops, err := Decode(tee, fn)
30+
31+
if werr := command.Wait(); werr != nil {
32+
return nil, "", fmt.Errorf("error finishing traceroute: %w", werr)
33+
}
34+
35+
if err != nil {
36+
// consume all output to have available in buf
37+
io.ReadAll(tee)
38+
// This is already a TracerouteError no need to wrap it
39+
return nil, buf.String(), err
40+
}
41+
42+
return hops, "", nil
43+
}
44+
45+
func Decode(reader io.Reader, fn DecodeLineFunc) ([]*Hop, error) {
46+
scanner := bufio.NewScanner(reader)
47+
scanner.Split(bufio.ScanLines)
48+
49+
var hops []*Hop
50+
for scanner.Scan() {
51+
text := scanner.Text()
52+
hop, err := fn(text)
53+
if err != nil {
54+
return nil, fmt.Errorf("error decoding output line: %w", err)
55+
}
56+
57+
hops = append(hops, hop)
58+
}
59+
60+
if scanner.Err() != nil {
61+
return nil, fmt.Errorf("scanner reported an error: %w", scanner.Err())
62+
}
63+
64+
return hops, nil
65+
}

0 commit comments

Comments
 (0)