tracepath 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +221 -0
- data/docs/platforms.md +94 -0
- data/docs/releasing.md +16 -0
- data/docs/verification.md +116 -0
- data/exe/tracepath +8 -0
- data/lib/tracepath/cli/elevated.rb +27 -0
- data/lib/tracepath/cli/privilege_check.rb +65 -0
- data/lib/tracepath/cli/privileges.rb +76 -0
- data/lib/tracepath/cli/text_output.rb +122 -0
- data/lib/tracepath/cli.rb +254 -0
- data/lib/tracepath/errors.rb +33 -0
- data/lib/tracepath/models.rb +138 -0
- data/lib/tracepath/options.rb +89 -0
- data/lib/tracepath/packet.rb +433 -0
- data/lib/tracepath/pcap.rb +219 -0
- data/lib/tracepath/resolver.rb +82 -0
- data/lib/tracepath/runner.rb +248 -0
- data/lib/tracepath/transport.rb +705 -0
- data/lib/tracepath/version.rb +5 -0
- data/lib/tracepath.rb +110 -0
- metadata +126 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Tracepath
|
|
6
|
+
# A small libpcap boundary. It captures replies; it never sends packets.
|
|
7
|
+
class Pcap
|
|
8
|
+
MAX_PACKETS = 64
|
|
9
|
+
MAX_CAPTURE_LENGTH = 65_535 + 256
|
|
10
|
+
ERROR_BUFFER_SIZE = 256
|
|
11
|
+
|
|
12
|
+
attr_reader :io, :datalink
|
|
13
|
+
|
|
14
|
+
def initialize(interface:, source:, destination:, native: nil)
|
|
15
|
+
@native = native || Native.new
|
|
16
|
+
@closed = false
|
|
17
|
+
opened = false
|
|
18
|
+
begin
|
|
19
|
+
source_ip = IPAddr.new(source.split("%", 2).first)
|
|
20
|
+
destination_ip = IPAddr.new(destination.split("%", 2).first)
|
|
21
|
+
@handle = @native.create(interface)
|
|
22
|
+
@native.configure(@handle)
|
|
23
|
+
# An IPv6 extension header can precede TCP. A plain "tcp" BPF
|
|
24
|
+
# predicate would miss these packets, so decode the IP payload later.
|
|
25
|
+
protocol_filter = source_ip.ipv4? ? "ip and tcp" : "ip6"
|
|
26
|
+
filter = "#{protocol_filter} and src host #{destination_ip} and dst host #{source_ip}"
|
|
27
|
+
@native.filter(@handle, filter)
|
|
28
|
+
@datalink = @native.datalink(@handle)
|
|
29
|
+
unless [0, 1, 12, 101, 108, 228, 229].include?(@datalink)
|
|
30
|
+
raise NetworkError.new(:unsupported_feature, "Unsupported capture link type #{@datalink}",
|
|
31
|
+
details: { datalink: @datalink, interface: interface })
|
|
32
|
+
end
|
|
33
|
+
descriptor = @native.selectable_fd(@handle)
|
|
34
|
+
if descriptor.negative?
|
|
35
|
+
raise NetworkError.new(:unsupported_feature, "Capture does not expose a selectable descriptor",
|
|
36
|
+
details: { interface: interface })
|
|
37
|
+
end
|
|
38
|
+
# libpcap owns this descriptor. Ruby must not close it during GC.
|
|
39
|
+
@io = IO.for_fd(descriptor, autoclose: false)
|
|
40
|
+
opened = true
|
|
41
|
+
ensure
|
|
42
|
+
unless opened
|
|
43
|
+
primary_error = $!
|
|
44
|
+
begin
|
|
45
|
+
close
|
|
46
|
+
rescue NetworkError, SystemCallError, IOError
|
|
47
|
+
raise unless primary_error
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Never blocks: the handle is configured in nonblocking mode. The caller
|
|
54
|
+
# owns the select/deadline loop, including cancellation responsiveness.
|
|
55
|
+
def read(limit: MAX_PACKETS)
|
|
56
|
+
return [] if @closed
|
|
57
|
+
|
|
58
|
+
packets = []
|
|
59
|
+
maximum = [limit, MAX_PACKETS].min
|
|
60
|
+
maximum.times do
|
|
61
|
+
packet = @native.next_packet(@handle)
|
|
62
|
+
break unless packet
|
|
63
|
+
|
|
64
|
+
observation = { data: packet, datalink: @datalink, received_at: Clock.now }
|
|
65
|
+
packets << observation
|
|
66
|
+
# Deliver each copied frame before a later native read can fail.
|
|
67
|
+
yield observation if block_given?
|
|
68
|
+
end
|
|
69
|
+
# libpcap can retain unread packets after draining the underlying BPF
|
|
70
|
+
# descriptor. A full batch means select alone cannot decide readiness.
|
|
71
|
+
@buffered = maximum.positive? && packets.length == maximum
|
|
72
|
+
packets
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def buffered? = !!@buffered
|
|
76
|
+
|
|
77
|
+
def close
|
|
78
|
+
return if @closed
|
|
79
|
+
|
|
80
|
+
@closed = true
|
|
81
|
+
@buffered = false
|
|
82
|
+
begin
|
|
83
|
+
@native.close(@handle) if @handle
|
|
84
|
+
ensure
|
|
85
|
+
@handle = nil
|
|
86
|
+
@io = nil
|
|
87
|
+
end
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Load only when macOS TCP capture is requested. No C compiler or pcap gem
|
|
92
|
+
# is required, and no global Fiddle pointers escape this object.
|
|
93
|
+
class Native
|
|
94
|
+
SIGNATURES = {
|
|
95
|
+
"pcap_create" => [:pointer, [:pointer, :pointer]],
|
|
96
|
+
"pcap_set_snaplen" => [:int, [:pointer, :int]],
|
|
97
|
+
"pcap_set_promisc" => [:int, [:pointer, :int]],
|
|
98
|
+
"pcap_set_timeout" => [:int, [:pointer, :int]],
|
|
99
|
+
"pcap_set_immediate_mode" => [:int, [:pointer, :int]],
|
|
100
|
+
"pcap_activate" => [:int, [:pointer]],
|
|
101
|
+
"pcap_setnonblock" => [:int, [:pointer, :int, :pointer]],
|
|
102
|
+
"pcap_compile" => [:int, [:pointer, :pointer, :pointer, :int, :int]],
|
|
103
|
+
"pcap_setfilter" => [:int, [:pointer, :pointer]],
|
|
104
|
+
"pcap_freecode" => [:void, [:pointer]],
|
|
105
|
+
"pcap_datalink" => [:int, [:pointer]],
|
|
106
|
+
"pcap_get_selectable_fd" => [:int, [:pointer]],
|
|
107
|
+
"pcap_next_ex" => [:int, [:pointer, :pointer, :pointer]],
|
|
108
|
+
"pcap_geterr" => [:pointer, [:pointer]],
|
|
109
|
+
"pcap_close" => [:void, [:pointer]]
|
|
110
|
+
}.freeze
|
|
111
|
+
|
|
112
|
+
def initialize(library: "/usr/lib/libpcap.A.dylib")
|
|
113
|
+
require "fiddle"
|
|
114
|
+
@library = Fiddle.dlopen(library)
|
|
115
|
+
@functions = SIGNATURES.to_h do |name, (result, arguments)|
|
|
116
|
+
types = { pointer: Fiddle::TYPE_VOIDP, int: Fiddle::TYPE_INT, void: Fiddle::TYPE_VOID }
|
|
117
|
+
[name, Fiddle::Function.new(@library[name], arguments.map { |type| types.fetch(type) }, types.fetch(result))]
|
|
118
|
+
end
|
|
119
|
+
# pcap_pkthdr is two native longs (timeval), followed by two uint32s.
|
|
120
|
+
# bpf_program is uint32 length, padding, then a native pointer.
|
|
121
|
+
@header_size = (2 * Fiddle::SIZEOF_LONG) + 8
|
|
122
|
+
@bpf_size = ((4 + Fiddle::SIZEOF_VOIDP - 1) / Fiddle::SIZEOF_VOIDP + 1) * Fiddle::SIZEOF_VOIDP
|
|
123
|
+
rescue LoadError => error
|
|
124
|
+
raise NetworkError.new(:unsupported_feature, "TCP capture requires the fiddle gem", details: { cause: error.message })
|
|
125
|
+
rescue Fiddle::DLError => error
|
|
126
|
+
raise NetworkError.new(:unsupported_feature, "Cannot load the system libpcap", details: { cause: error.message })
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def create(interface)
|
|
130
|
+
error_buffer = buffer(ERROR_BUFFER_SIZE)
|
|
131
|
+
handle = call("pcap_create", interface, error_buffer)
|
|
132
|
+
if handle.null?
|
|
133
|
+
raise NetworkError.new(:capture_error, "Cannot create capture: #{error_buffer.to_s}")
|
|
134
|
+
end
|
|
135
|
+
handle
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def configure(handle)
|
|
139
|
+
{
|
|
140
|
+
"pcap_set_snaplen" => MAX_CAPTURE_LENGTH,
|
|
141
|
+
"pcap_set_promisc" => 0,
|
|
142
|
+
"pcap_set_timeout" => 1,
|
|
143
|
+
"pcap_set_immediate_mode" => 1
|
|
144
|
+
}.each { |name, value| check(call(name, handle, value), handle) }
|
|
145
|
+
check(call("pcap_activate", handle), handle)
|
|
146
|
+
error_buffer = buffer(ERROR_BUFFER_SIZE)
|
|
147
|
+
status = call("pcap_setnonblock", handle, 1, error_buffer)
|
|
148
|
+
if status.negative?
|
|
149
|
+
raise NetworkError.new(:capture_error, "Cannot make capture nonblocking: #{error_buffer.to_s}")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def filter(handle, expression)
|
|
154
|
+
program = buffer(@bpf_size)
|
|
155
|
+
compiled = false
|
|
156
|
+
begin
|
|
157
|
+
check(call("pcap_compile", handle, program, expression, 1, -1), handle)
|
|
158
|
+
compiled = true
|
|
159
|
+
check(call("pcap_setfilter", handle, program), handle)
|
|
160
|
+
ensure
|
|
161
|
+
call("pcap_freecode", program) if compiled
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def datalink(handle)
|
|
166
|
+
call("pcap_datalink", handle)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def selectable_fd(handle)
|
|
170
|
+
call("pcap_get_selectable_fd", handle)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def next_packet(handle)
|
|
174
|
+
header_pointer = buffer(Fiddle::SIZEOF_VOIDP)
|
|
175
|
+
data_pointer = buffer(Fiddle::SIZEOF_VOIDP)
|
|
176
|
+
status = call("pcap_next_ex", handle, header_pointer, data_pointer)
|
|
177
|
+
return nil if status.zero? || status == -2
|
|
178
|
+
|
|
179
|
+
check(status, handle)
|
|
180
|
+
header_address = header_pointer[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
|
|
181
|
+
data_address = data_pointer[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
|
|
182
|
+
if header_address.zero? || data_address.zero?
|
|
183
|
+
raise NetworkError.new(:capture_error, "Capture returned an invalid packet pointer")
|
|
184
|
+
end
|
|
185
|
+
header = Fiddle::Pointer.new(header_address)[0, @header_size]
|
|
186
|
+
captured_length, original_length = header.byteslice(2 * Fiddle::SIZEOF_LONG, 8).unpack("LL")
|
|
187
|
+
unless captured_length <= MAX_CAPTURE_LENGTH && captured_length <= original_length
|
|
188
|
+
raise NetworkError.new(:capture_error, "Capture returned an invalid packet length")
|
|
189
|
+
end
|
|
190
|
+
# Copy before another pcap read invalidates the borrowed buffer.
|
|
191
|
+
Fiddle::Pointer.new(data_address)[0, captured_length]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def close(handle)
|
|
195
|
+
call("pcap_close", handle)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def buffer(size)
|
|
201
|
+
pointer = Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
|
|
202
|
+
pointer[0, size] = "\0" * size
|
|
203
|
+
pointer
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def call(name, *arguments)
|
|
207
|
+
@functions.fetch(name).call(*arguments)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def check(status, handle)
|
|
211
|
+
return status unless status.negative?
|
|
212
|
+
|
|
213
|
+
code = [-8, -11].include?(status) ? :permission_denied : :capture_error
|
|
214
|
+
message = call("pcap_geterr", handle).to_s
|
|
215
|
+
raise NetworkError.new(code, "Capture failed: #{message}", details: { pcap_status: status })
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Tracepath
|
|
7
|
+
class Resolver
|
|
8
|
+
class LookupTimeout < StandardError; end
|
|
9
|
+
|
|
10
|
+
def initialize(clock: Clock)
|
|
11
|
+
@clock = clock
|
|
12
|
+
@reverse_cache = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolve(original, options, deadline:)
|
|
16
|
+
budget = [options.dns_timeout, deadline - @clock.now].min
|
|
17
|
+
raise NetworkError.new(:resolution_timeout, "name resolution deadline exceeded") unless budget.positive?
|
|
18
|
+
family = { auto: Socket::AF_UNSPEC, ipv4: Socket::AF_INET, ipv6: Socket::AF_INET6 }.fetch(options.family)
|
|
19
|
+
address_text, zone = original.split("%", 2)
|
|
20
|
+
numeric = begin
|
|
21
|
+
IPAddr.new(address_text)
|
|
22
|
+
rescue IPAddr::InvalidAddressError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
raise NetworkError.new(:invalid_address, "a scope is only valid for a numeric IPv6 address") if zone && !numeric&.ipv6?
|
|
26
|
+
address = Addrinfo.getaddrinfo(original, nil, family, Socket::SOCK_DGRAM, 0, 0, timeout: budget).first
|
|
27
|
+
raise NetworkError.new(:resolution_error, "no address available for #{original}") unless address
|
|
28
|
+
selected = address.ip_address.split("%", 2).first
|
|
29
|
+
ip = IPAddr.new(selected)
|
|
30
|
+
if ip.to_i.zero? || ip.ipv4? && (ip.to_i >= 0xe0000000) || ip.ipv6? && (ip.to_i >> 120) == 0xff
|
|
31
|
+
raise NetworkError.new(:invalid_address, "target must be a unicast address")
|
|
32
|
+
end
|
|
33
|
+
resolved_zone = address.ip_address.split("%", 2)[1]
|
|
34
|
+
scope_id = (zone || resolved_zone) ? interface_index(zone || resolved_zone) : nil
|
|
35
|
+
if ip.ipv6? && IPAddr.new("fe80::/10").include?(ip)
|
|
36
|
+
scope_id ||= options.interface && interface_index(options.interface)
|
|
37
|
+
raise NetworkError.new(:invalid_address, "link-local IPv6 targets require a scope or interface") unless scope_id
|
|
38
|
+
end
|
|
39
|
+
if options.interface && scope_id && interface_index(options.interface) != scope_id
|
|
40
|
+
raise NetworkError.new(:invalid_address, "target scope and interface disagree")
|
|
41
|
+
end
|
|
42
|
+
chosen_family = address.ipv4? ? :ipv4 : :ipv6
|
|
43
|
+
if chosen_family == :ipv6 && options.dont_fragment
|
|
44
|
+
raise NetworkError.new(:unsupported_feature, "dont_fragment only applies to IPv4")
|
|
45
|
+
end
|
|
46
|
+
Target.new(original: original, address: selected, family: chosen_family, scope_id: scope_id)
|
|
47
|
+
rescue SocketError => error
|
|
48
|
+
raise NetworkError.new(:resolution_error, error.message)
|
|
49
|
+
rescue Errno::ETIMEDOUT
|
|
50
|
+
raise NetworkError.new(:resolution_timeout, "name resolution timed out for #{original}")
|
|
51
|
+
rescue SystemCallError => error
|
|
52
|
+
raise NetworkError.new(:resolution_error, error.message)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reverse(address, timeout:, deadline:)
|
|
56
|
+
require "resolv"
|
|
57
|
+
return @reverse_cache[address] if @reverse_cache.key?(address)
|
|
58
|
+
budget = [timeout, deadline - @clock.now].min
|
|
59
|
+
return nil unless budget.positive?
|
|
60
|
+
hostname = Timeout.timeout(budget, LookupTimeout) do
|
|
61
|
+
Resolv::Hosts.new.getname(address)
|
|
62
|
+
rescue Resolv::ResolvError
|
|
63
|
+
Resolv::DNS.open do |dns|
|
|
64
|
+
dns.timeouts = [budget]
|
|
65
|
+
dns.getname(address).to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
@reverse_cache[address] = hostname.to_s.freeze
|
|
69
|
+
rescue LookupTimeout, Resolv::ResolvError, Resolv::ResolvTimeout, IOError, SystemCallError
|
|
70
|
+
@reverse_cache[address] = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def interface_index(value)
|
|
76
|
+
return Integer(value, 10) if value.match?(/\A[1-9]\d*\z/)
|
|
77
|
+
entry = Socket.getifaddrs.find { |item| item.name == value }
|
|
78
|
+
raise NetworkError.new(:invalid_interface, "interface #{value.inspect} does not exist") unless entry
|
|
79
|
+
entry.ifindex
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Tracepath
|
|
6
|
+
class RateLimiter
|
|
7
|
+
def initialize(rate, clock: Clock)
|
|
8
|
+
unless rate.is_a?(Numeric) && rate.real? && rate.finite? && rate.positive?
|
|
9
|
+
raise ArgumentError, "rate_limit must be a finite positive number"
|
|
10
|
+
end
|
|
11
|
+
@interval = 1.0 / rate
|
|
12
|
+
@next_send = -Float::INFINITY
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def acquire(now)
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
return false if now < @next_send
|
|
19
|
+
@next_send = now + @interval
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def wait_time(now) = @mutex.synchronize { [@next_send - now, 0.0].max }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# One runner owns one transport. No socket option is shared between targets.
|
|
28
|
+
class Runner
|
|
29
|
+
WAIT_SLICE = 0.05
|
|
30
|
+
|
|
31
|
+
def initialize(request:, options:, cancellation:, limiter:, clock: Clock,
|
|
32
|
+
resolver: nil, transport_factory: Transport, on_event: nil)
|
|
33
|
+
@request, @options, @cancellation = request, options, cancellation
|
|
34
|
+
@limiter, @clock, @transport_factory = limiter, clock, transport_factory
|
|
35
|
+
@resolver = resolver || Resolver.new(clock: clock)
|
|
36
|
+
@on_event = on_event
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run
|
|
40
|
+
outcome = nil
|
|
41
|
+
begin
|
|
42
|
+
outcome = execute { |hop| yield hop if block_given? }
|
|
43
|
+
ensure
|
|
44
|
+
primary_error = $!
|
|
45
|
+
begin
|
|
46
|
+
@transport&.close
|
|
47
|
+
rescue NetworkError => error
|
|
48
|
+
if !primary_error && outcome
|
|
49
|
+
reason = %i[cancelled deadline_exceeded].include?(outcome.stop_reason) ? outcome.stop_reason : :error
|
|
50
|
+
outcome = outcome.with(stop_reason: reason, error: outcome.error || ErrorInfo.from(error))
|
|
51
|
+
end
|
|
52
|
+
rescue Exception
|
|
53
|
+
raise unless primary_error
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
outcome
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def execute
|
|
62
|
+
@started_at = Time.now.utc
|
|
63
|
+
@started = @clock.now
|
|
64
|
+
@deadline = @started + @options.max_duration
|
|
65
|
+
@target = Target.new(original: @request.target)
|
|
66
|
+
@hops = []
|
|
67
|
+
return result(:cancelled) if @cancellation.cancelled?
|
|
68
|
+
@target = @resolver.resolve(@request.target, @options, deadline: @deadline)
|
|
69
|
+
return result(:cancelled) if @cancellation.cancelled?
|
|
70
|
+
return result(:deadline_exceeded) if @clock.now >= @deadline
|
|
71
|
+
@transport = @transport_factory.open(target: @target, options: @options)
|
|
72
|
+
if @on_event
|
|
73
|
+
packet_size = (@target.family == :ipv6 ? 40 : 20) + (@options.protocol == :tcp ? 20 : 8) + @transport.payload_size
|
|
74
|
+
emit_event(:trace_started, target: @target, options: @options, packet_size: packet_size)
|
|
75
|
+
end
|
|
76
|
+
@next_send = @clock.now
|
|
77
|
+
@ordinal = 0
|
|
78
|
+
(@options.first_ttl..@options.max_hops).each do |ttl|
|
|
79
|
+
reason = interruption
|
|
80
|
+
return result(reason) if reason
|
|
81
|
+
emit_event(:hop_started, ttl: ttl) if @on_event
|
|
82
|
+
hop, reason = trace_hop(ttl)
|
|
83
|
+
hop = resolve_names(hop) if @options.reverse_dns && !@cancellation.cancelled?
|
|
84
|
+
@hops << hop
|
|
85
|
+
yield hop if block_given?
|
|
86
|
+
reason ||= interruption
|
|
87
|
+
return result(reason) if reason
|
|
88
|
+
end
|
|
89
|
+
result(:max_hops)
|
|
90
|
+
rescue NetworkError => error
|
|
91
|
+
result(interruption || :error, error: ErrorInfo.from(error))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def trace_hop(ttl)
|
|
95
|
+
results = Array.new(@options.probes)
|
|
96
|
+
pending = {}
|
|
97
|
+
next_index = 0
|
|
98
|
+
terminal_error = nil
|
|
99
|
+
interrupted = nil
|
|
100
|
+
|
|
101
|
+
loop do
|
|
102
|
+
now = @clock.now
|
|
103
|
+
interrupted = interruption
|
|
104
|
+
if interrupted
|
|
105
|
+
cancel_remaining_probes(results, pending, next_index, ttl)
|
|
106
|
+
break
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
pending.delete_if do |_sequence, probe|
|
|
110
|
+
expired = now >= probe.deadline
|
|
111
|
+
finish_probe(results, ProbeResult.new(probe: probe, status: :timed_out)) if expired
|
|
112
|
+
expired
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
while next_index < @options.probes && pending.size < @options.max_inflight && now >= @next_send && @limiter.acquire(now)
|
|
116
|
+
break if @cancellation.cancelled? || now >= @deadline
|
|
117
|
+
probe = build_probe(ttl, next_index, now)
|
|
118
|
+
begin
|
|
119
|
+
@transport.send_probe(probe)
|
|
120
|
+
pending[probe.sequence] = probe
|
|
121
|
+
rescue NetworkError => error
|
|
122
|
+
terminal_error = ErrorInfo.from(error)
|
|
123
|
+
record_replies(error.replies, results, pending)
|
|
124
|
+
finish_probe(results, ProbeResult.new(probe: probe, status: :send_error, error: terminal_error))
|
|
125
|
+
end
|
|
126
|
+
next_index += 1
|
|
127
|
+
@next_send = now + @options.send_interval
|
|
128
|
+
now = @clock.now
|
|
129
|
+
break if terminal_error
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if terminal_error
|
|
133
|
+
cancel_remaining_probes(results, pending, next_index, ttl)
|
|
134
|
+
break
|
|
135
|
+
end
|
|
136
|
+
break if next_index == @options.probes && pending.empty?
|
|
137
|
+
|
|
138
|
+
waits = [WAIT_SLICE, @deadline - now]
|
|
139
|
+
waits.concat(pending.values.map { |probe| probe.deadline - now })
|
|
140
|
+
if next_index < @options.probes && pending.size < @options.max_inflight
|
|
141
|
+
waits << [@next_send - now, @limiter.wait_time(now)].max
|
|
142
|
+
end
|
|
143
|
+
replies = begin
|
|
144
|
+
@transport.receive([waits.min, 0.0].max)
|
|
145
|
+
rescue NetworkError => error
|
|
146
|
+
terminal_error = ErrorInfo.from(error)
|
|
147
|
+
record_replies(error.replies, results, pending)
|
|
148
|
+
cancel_remaining_probes(results, pending, next_index, ttl)
|
|
149
|
+
break
|
|
150
|
+
end
|
|
151
|
+
record_replies(replies, results, pending)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
@run_error = terminal_error if terminal_error
|
|
155
|
+
hop = Hop.new(ttl: ttl, probes: results)
|
|
156
|
+
# Evidence obtained before cancellation/deadline still proves arrival.
|
|
157
|
+
reason = if results.any? { |item| item.replied? && Packet.destination_reached?(item.reply, item.probe) }
|
|
158
|
+
:reached
|
|
159
|
+
elsif interrupted
|
|
160
|
+
interrupted
|
|
161
|
+
elsif terminal_error
|
|
162
|
+
:error
|
|
163
|
+
else
|
|
164
|
+
terminal_reason(results)
|
|
165
|
+
end
|
|
166
|
+
[hop, reason]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def record_replies(replies, results, pending)
|
|
170
|
+
replies.each do |reply|
|
|
171
|
+
received_at = reply.fetch(:received_at, @clock.now)
|
|
172
|
+
pair = pending.find { |_sequence, probe| Packet.match?(reply, probe) }
|
|
173
|
+
next unless pair
|
|
174
|
+
|
|
175
|
+
sequence, probe = pair
|
|
176
|
+
# Partial reads retain the same correlation and deadline checks.
|
|
177
|
+
next if received_at < probe.sent_at || received_at > probe.deadline || received_at > @deadline
|
|
178
|
+
|
|
179
|
+
finish_probe(results, ProbeResult.new(probe: probe, status: :reply,
|
|
180
|
+
reply: reply, rtt: received_at - probe.sent_at))
|
|
181
|
+
pending.delete(sequence)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def finish_probe(results, item)
|
|
186
|
+
results[item.probe.index] = item
|
|
187
|
+
emit_event(:probe_finished, result: item) if @on_event
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def cancel_remaining_probes(results, pending, next_index, ttl)
|
|
191
|
+
pending.each_value { |probe| finish_probe(results, ProbeResult.new(probe: probe, status: :cancelled)) }
|
|
192
|
+
(next_index...@options.probes).each do |index|
|
|
193
|
+
finish_probe(results, ProbeResult.new(probe: build_probe(ttl, index, nil), status: :cancelled))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def emit_event(type, **attributes)
|
|
198
|
+
@on_event.call(Values.copy({ type: type, **attributes }))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def build_probe(ttl, index, now)
|
|
202
|
+
@ordinal += 1
|
|
203
|
+
Probe.new(family: @target.family, protocol: @options.protocol,
|
|
204
|
+
source_address: @transport.source_address, destination_address: @target.address,
|
|
205
|
+
source_port: @transport.source_port,
|
|
206
|
+
destination_port: @options.protocol == :udp ? @options.port + @ordinal - 1 : @options.port,
|
|
207
|
+
identifier: @transport.identifier, sequence: @ordinal,
|
|
208
|
+
tcp_sequence: @options.protocol == :tcp ? SecureRandom.random_number(1 << 32) : nil,
|
|
209
|
+
ttl: ttl, index: index, sent_at: now, deadline: now && [now + @options.timeout, @deadline].min,
|
|
210
|
+
payload_size: @transport.payload_size)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def terminal_reason(results)
|
|
214
|
+
replies = results.select(&:replied?).map(&:reply)
|
|
215
|
+
return nil if replies.any? { |reply| reply[:kind] == :time_exceeded && reply[:icmp_code] == 0 }
|
|
216
|
+
return :unreachable if replies.any? { |reply| reply[:kind] == :unreachable }
|
|
217
|
+
failure = replies.find { |reply| %i[packet_too_big parameter_problem time_exceeded].include?(reply[:kind]) }
|
|
218
|
+
return nil unless failure
|
|
219
|
+
code = if failure[:kind] == :time_exceeded
|
|
220
|
+
failure[:icmp_code] == 1 ? :reassembly_timeout : :icmp_error
|
|
221
|
+
else
|
|
222
|
+
failure[:kind]
|
|
223
|
+
end
|
|
224
|
+
@run_error = ErrorInfo.new(code: code, message: "probe rejected: #{code}",
|
|
225
|
+
details: failure.slice(:source, :icmp_type, :icmp_code, :mtu))
|
|
226
|
+
:error
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def resolve_names(hop)
|
|
230
|
+
probes = hop.probes.map do |item|
|
|
231
|
+
next item unless item.address && !@cancellation.cancelled? && @clock.now < @deadline
|
|
232
|
+
name = @resolver.reverse(item.address, timeout: @options.ptr_timeout, deadline: @deadline)
|
|
233
|
+
name ? item.with(hostname: name) : item
|
|
234
|
+
end
|
|
235
|
+
Hop.new(ttl: hop.ttl, probes: probes)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def interruption
|
|
239
|
+
return :cancelled if @cancellation.cancelled?
|
|
240
|
+
:deadline_exceeded if @clock.now >= @deadline
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def result(reason, error: @run_error)
|
|
244
|
+
Result.new(target: @target, options: @options, hops: @hops, started_at: @started_at,
|
|
245
|
+
duration: @clock.now - @started, stop_reason: reason, error: error)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|