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,705 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "pcap"
|
|
7
|
+
|
|
8
|
+
module Tracepath
|
|
9
|
+
# Socket policy and operating-system differences live here, outside the
|
|
10
|
+
# scheduler and packet codecs. This object belongs to one trace.
|
|
11
|
+
class Transport
|
|
12
|
+
MAX_PACKETS = 64
|
|
13
|
+
LINUX_IP_RECVERR = 11
|
|
14
|
+
LINUX_IPV6_RECVERR = 25
|
|
15
|
+
LINUX_MSG_ERRQUEUE = 0x2000
|
|
16
|
+
LINUX_SOL_PACKET = 263
|
|
17
|
+
LINUX_PACKET_AUXDATA = 8
|
|
18
|
+
LINUX_CSUM_NOT_READY = 1 << 3
|
|
19
|
+
LINUX_CSUM_VALID = 1 << 7
|
|
20
|
+
IDENTIFIER_MUTEX = Mutex.new
|
|
21
|
+
IDENTIFIERS = {}
|
|
22
|
+
|
|
23
|
+
def self.allocate_identifier
|
|
24
|
+
IDENTIFIER_MUTEX.synchronize do
|
|
25
|
+
start = SecureRandom.random_number(1..65_535)
|
|
26
|
+
65_535.times do |offset|
|
|
27
|
+
identifier = ((start + offset - 1) % 65_535) + 1
|
|
28
|
+
next if IDENTIFIERS.key?(identifier)
|
|
29
|
+
|
|
30
|
+
IDENTIFIERS[identifier] = true
|
|
31
|
+
return identifier
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
raise NetworkError.new(:resource_exhausted, "All ICMP identifiers are in use")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.release_identifier(identifier)
|
|
38
|
+
IDENTIFIER_MUTEX.synchronize { IDENTIFIERS.delete(identifier) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :source_address, :source_port, :identifier, :payload_size
|
|
42
|
+
|
|
43
|
+
def self.open(target:, options:)
|
|
44
|
+
new(target: target, options: options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(target:, options:)
|
|
48
|
+
@target = target
|
|
49
|
+
@options = options
|
|
50
|
+
@family = target.family
|
|
51
|
+
@socket_family = @family == :ipv4 ? Socket::AF_INET : Socket::AF_INET6
|
|
52
|
+
@platform = RUBY_PLATFORM.include?("linux") ? :linux : (RUBY_PLATFORM.include?("darwin") ? :darwin : nil)
|
|
53
|
+
@resources = []
|
|
54
|
+
@readers = {}
|
|
55
|
+
@udp_probes = {}
|
|
56
|
+
@pending_replies = []
|
|
57
|
+
@reader_cursor = 0
|
|
58
|
+
@closed = false
|
|
59
|
+
opened = false
|
|
60
|
+
begin
|
|
61
|
+
unless @platform
|
|
62
|
+
raise NetworkError.new(:unsupported_feature, "Native tracing supports Linux and macOS")
|
|
63
|
+
end
|
|
64
|
+
@interfaces = Socket.getifaddrs
|
|
65
|
+
validate_interface!
|
|
66
|
+
choose_source!
|
|
67
|
+
@payload_size = payload_length
|
|
68
|
+
@payload = "\0".b * @payload_size
|
|
69
|
+
open_sender
|
|
70
|
+
open_receivers
|
|
71
|
+
opened = true
|
|
72
|
+
rescue SystemCallError, SocketError => error
|
|
73
|
+
raise network_error(error)
|
|
74
|
+
ensure
|
|
75
|
+
unless opened
|
|
76
|
+
primary_error = $!
|
|
77
|
+
begin
|
|
78
|
+
close
|
|
79
|
+
rescue NetworkError
|
|
80
|
+
raise unless primary_error
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def send_probe(probe)
|
|
87
|
+
raise NetworkError.new(:closed, "Transport is closed") if @closed
|
|
88
|
+
|
|
89
|
+
level, option = @family == :ipv4 ? [Socket::IPPROTO_IP, Socket::IP_TTL] : [Socket::IPPROTO_IPV6, Socket::IPV6_UNICAST_HOPS]
|
|
90
|
+
@sender.setsockopt(level, option, probe.ttl)
|
|
91
|
+
data = case @options.protocol
|
|
92
|
+
when :udp
|
|
93
|
+
@payload
|
|
94
|
+
when :icmp
|
|
95
|
+
Packet.icmp_echo(family: @family, identifier: probe.identifier, sequence: probe.sequence, payload: @payload)
|
|
96
|
+
when :tcp
|
|
97
|
+
Packet.tcp_syn(family: @family, source: @source_address, destination: @target.address,
|
|
98
|
+
source_port: probe.source_port, destination_port: probe.destination_port,
|
|
99
|
+
sequence: probe.tcp_sequence, payload: @payload)
|
|
100
|
+
end
|
|
101
|
+
destination_port = @options.protocol == :udp ? probe.destination_port : 0
|
|
102
|
+
read_error_queue(@sender, @pending_replies, MAX_PACKETS) if @error_queue
|
|
103
|
+
destination = sockaddr(@target.address, destination_port, @target.scope_id || @scope_id)
|
|
104
|
+
attempts = 0
|
|
105
|
+
begin
|
|
106
|
+
attempts += 1
|
|
107
|
+
sent = @sender.sendmsg_nonblock(data, 0, destination, exception: false)
|
|
108
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EACCES, Errno::EMSGSIZE
|
|
109
|
+
# An asynchronous UDP error can arrive between draining the error
|
|
110
|
+
# queue and sending. Preserve its reply, then retry this unsent probe
|
|
111
|
+
# once. Local errors in the queue still raise normally.
|
|
112
|
+
raise unless @error_queue && attempts == 1 && read_error_queue(@sender, @pending_replies, MAX_PACKETS).positive?
|
|
113
|
+
|
|
114
|
+
retry
|
|
115
|
+
end
|
|
116
|
+
unless sent == data.bytesize
|
|
117
|
+
raise NetworkError.new(:send_error, "Probe could not be sent without blocking")
|
|
118
|
+
end
|
|
119
|
+
@udp_probes[probe.destination_port] = probe if @error_queue
|
|
120
|
+
nil
|
|
121
|
+
rescue NetworkError, SystemCallError, SocketError => error
|
|
122
|
+
raise network_error(error, replies: @pending_replies.shift(@pending_replies.length))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def receive(timeout)
|
|
126
|
+
return [] if @closed
|
|
127
|
+
# The I/O budget limits new reads, not delivery of records already read.
|
|
128
|
+
# Leaving these behind would lose them if the next loop is cancelled.
|
|
129
|
+
return @pending_replies.shift(@pending_replies.length) unless @pending_replies.empty?
|
|
130
|
+
|
|
131
|
+
deadline = Clock.now + [Float(timeout), 0.0].max
|
|
132
|
+
buffered = @pcap&.buffered?
|
|
133
|
+
wait = buffered ? 0.0 : [deadline - Clock.now, 0.0].max
|
|
134
|
+
readable = IO.select(@readers.keys, nil, nil, wait)
|
|
135
|
+
ready = readable ? readable.first : []
|
|
136
|
+
ready |= [@pcap.io] if buffered
|
|
137
|
+
return [] if ready.empty?
|
|
138
|
+
|
|
139
|
+
replies = []
|
|
140
|
+
remaining = MAX_PACKETS
|
|
141
|
+
# Rotate ready descriptors so continuous ICMP traffic cannot starve a
|
|
142
|
+
# TCP terminal response waiting on another descriptor.
|
|
143
|
+
ready = ready.rotate(@reader_cursor % ready.length)
|
|
144
|
+
@reader_cursor += 1
|
|
145
|
+
ready.each do |io|
|
|
146
|
+
break if remaining.zero?
|
|
147
|
+
|
|
148
|
+
type = @readers.fetch(io)
|
|
149
|
+
if type == :pcap
|
|
150
|
+
@pcap.read(limit: remaining) do |packet|
|
|
151
|
+
remaining -= 1
|
|
152
|
+
reply = Packet.decode_link(packet.fetch(:data), datalink: packet.fetch(:datalink),
|
|
153
|
+
local_capture: !!@local_capture)
|
|
154
|
+
if reply
|
|
155
|
+
reply = with_interface_context(reply, interface_index: @capture_interface_index)
|
|
156
|
+
replies << reply.merge(received_at: packet.fetch(:received_at))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
elsif type == :error_queue
|
|
160
|
+
read_error_queue(io, replies, remaining).then { |count| remaining -= count }
|
|
161
|
+
elsif type == :packet
|
|
162
|
+
read_packet_socket(io, replies, remaining).then { |count| remaining -= count }
|
|
163
|
+
else
|
|
164
|
+
read_socket(io, type, replies, remaining).then { |count| remaining -= count }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
replies
|
|
168
|
+
rescue Errno::EINTR
|
|
169
|
+
replies || []
|
|
170
|
+
rescue NetworkError, SystemCallError, SocketError => error
|
|
171
|
+
raise network_error(error, replies: replies || [])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def close
|
|
175
|
+
return if @closed
|
|
176
|
+
|
|
177
|
+
@closed = true
|
|
178
|
+
begin
|
|
179
|
+
error = close_resources([@pcap, *@resources.reverse].compact)
|
|
180
|
+
ensure
|
|
181
|
+
self.class.release_identifier(@identifier) if @identifier
|
|
182
|
+
@resources.clear
|
|
183
|
+
@readers.clear
|
|
184
|
+
@udp_probes.clear
|
|
185
|
+
@pending_replies.clear
|
|
186
|
+
end
|
|
187
|
+
raise error if error
|
|
188
|
+
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
# Recursion is bounded by the handful of descriptors owned by one trace.
|
|
195
|
+
# ensure also closes later resources when an unexpected programming error
|
|
196
|
+
# escapes, while expected close errors are reported after cleanup finishes.
|
|
197
|
+
def close_resources(resources)
|
|
198
|
+
resource, *remaining = resources
|
|
199
|
+
return unless resource
|
|
200
|
+
|
|
201
|
+
error = nil
|
|
202
|
+
begin
|
|
203
|
+
resource.close unless resource.respond_to?(:closed?) && resource.closed?
|
|
204
|
+
rescue NetworkError => failure
|
|
205
|
+
error = failure
|
|
206
|
+
rescue SystemCallError, IOError => failure
|
|
207
|
+
error = network_error(failure)
|
|
208
|
+
ensure
|
|
209
|
+
later_error = close_resources(remaining)
|
|
210
|
+
end
|
|
211
|
+
error || later_error
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_interface!
|
|
215
|
+
@interface = @options.interface
|
|
216
|
+
@interface_index = interface_index(@interface) if @interface
|
|
217
|
+
if @interface && !@interface_index
|
|
218
|
+
raise NetworkError.new(:invalid_interface, "Unknown interface #{@interface}")
|
|
219
|
+
end
|
|
220
|
+
if @target.scope_id && @interface_index && @target.scope_id != @interface_index
|
|
221
|
+
raise NetworkError.new(:invalid_interface, "Destination scope and interface disagree")
|
|
222
|
+
end
|
|
223
|
+
if @target.scope_id && !@interfaces.any? { |entry| entry.ifindex == @target.scope_id }
|
|
224
|
+
raise NetworkError.new(:invalid_interface, "Unknown destination scope #{@target.scope_id}")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def interface_index(name)
|
|
229
|
+
@interfaces.find { |entry| entry.name == name }&.ifindex
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def local_addresses
|
|
233
|
+
@interfaces.select { |entry| entry.addr&.ip? && entry.addr.afamily == @socket_family }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def choose_source!
|
|
237
|
+
source, zone = @options.source&.split("%", 2)
|
|
238
|
+
source_scope = if zone
|
|
239
|
+
zone.match?(/\A\d+\z/) ? zone.to_i : interface_index(zone)
|
|
240
|
+
end
|
|
241
|
+
if zone && (!source_scope || !@interfaces.any? { |entry| entry.ifindex == source_scope })
|
|
242
|
+
raise NetworkError.new(:invalid_source, "Unknown source scope #{zone}")
|
|
243
|
+
end
|
|
244
|
+
if source_scope && @interface_index && source_scope != @interface_index
|
|
245
|
+
raise NetworkError.new(:invalid_source, "Source scope and interface disagree")
|
|
246
|
+
end
|
|
247
|
+
if source_scope && @target.scope_id && source_scope != @target.scope_id
|
|
248
|
+
raise NetworkError.new(:invalid_source, "Source and destination scopes disagree")
|
|
249
|
+
end
|
|
250
|
+
@scope_id = source_scope || @target.scope_id || @interface_index
|
|
251
|
+
if source
|
|
252
|
+
source = IPAddr.new(source).to_s
|
|
253
|
+
candidates = local_addresses.select do |entry|
|
|
254
|
+
IPAddr.new(entry.addr.ip_address.split("%", 2).first).to_s == source &&
|
|
255
|
+
(!@interface_index || entry.ifindex == @interface_index) &&
|
|
256
|
+
(!source_scope || entry.ifindex == source_scope)
|
|
257
|
+
end
|
|
258
|
+
if candidates.empty?
|
|
259
|
+
raise NetworkError.new(:invalid_source, "Source address is not assigned to the selected local interface")
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
route_socket = Socket.new(@socket_family, Socket::SOCK_DGRAM, Socket::IPPROTO_UDP)
|
|
263
|
+
begin
|
|
264
|
+
bind_interface(route_socket)
|
|
265
|
+
route_socket.bind(sockaddr(source, 0, @scope_id)) if source
|
|
266
|
+
route_socket.connect(sockaddr(@target.address, @options.port || 33_434, @target.scope_id || @scope_id))
|
|
267
|
+
@source_address = route_socket.local_address.ip_address.split("%", 2).first
|
|
268
|
+
ensure
|
|
269
|
+
route_socket.close
|
|
270
|
+
end
|
|
271
|
+
@source_interfaces = local_addresses.select do |entry|
|
|
272
|
+
IPAddr.new(entry.addr.ip_address.split("%", 2).first).to_s == @source_address &&
|
|
273
|
+
(!@interface_index || entry.ifindex == @interface_index) &&
|
|
274
|
+
(!@scope_id || !IPAddr.new(@source_address).link_local? || entry.ifindex == @scope_id)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def sockaddr(address, port, scope = nil)
|
|
279
|
+
scoped = address
|
|
280
|
+
if @family == :ipv6 && scope && !address.include?("%") && IPAddr.new(address).link_local?
|
|
281
|
+
scoped = "#{address}%#{scope}"
|
|
282
|
+
end
|
|
283
|
+
Socket.sockaddr_in(port, scoped)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def payload_length
|
|
287
|
+
return @options.protocol == :tcp ? 0 : 32 unless @options.packet_size
|
|
288
|
+
|
|
289
|
+
ip_length = @family == :ipv4 ? 20 : 40
|
|
290
|
+
protocol_length = @options.protocol == :tcp ? 20 : 8
|
|
291
|
+
length = @options.packet_size - ip_length - protocol_length
|
|
292
|
+
if length.negative?
|
|
293
|
+
raise NetworkError.new(:invalid_packet_size, "Packet size is smaller than its IP and protocol headers")
|
|
294
|
+
end
|
|
295
|
+
length
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def socket(type, protocol)
|
|
299
|
+
resource = Socket.new(@socket_family, type, protocol)
|
|
300
|
+
@resources << resource
|
|
301
|
+
resource
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def icmp_protocol
|
|
305
|
+
@family == :ipv4 ? Socket::IPPROTO_ICMP : Socket::IPPROTO_ICMPV6
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def open_sender
|
|
309
|
+
case @options.protocol
|
|
310
|
+
when :udp
|
|
311
|
+
@sender = socket(Socket::SOCK_DGRAM, Socket::IPPROTO_UDP)
|
|
312
|
+
when :icmp
|
|
313
|
+
@sender = socket(Socket::SOCK_RAW, icmp_protocol)
|
|
314
|
+
@identifier = self.class.allocate_identifier
|
|
315
|
+
when :tcp
|
|
316
|
+
# Reserve the source port across concurrent traces without making a
|
|
317
|
+
# connection or allowing the kernel to retransmit our SYN probes.
|
|
318
|
+
reservation = socket(Socket::SOCK_STREAM, Socket::IPPROTO_TCP)
|
|
319
|
+
bind_interface(reservation)
|
|
320
|
+
reservation.bind(sockaddr(@source_address, 0, @scope_id))
|
|
321
|
+
@source_port = reservation.local_address.ip_port
|
|
322
|
+
@sender = socket(Socket::SOCK_RAW, Socket::IPPROTO_TCP)
|
|
323
|
+
end
|
|
324
|
+
bind_interface(@sender)
|
|
325
|
+
@sender.bind(sockaddr(@source_address, 0, @scope_id))
|
|
326
|
+
@source_port = @sender.local_address.ip_port if @options.protocol == :udp
|
|
327
|
+
configure_sender
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def configure_sender
|
|
331
|
+
if @options.traffic_class
|
|
332
|
+
level, option = @family == :ipv4 ? [Socket::IPPROTO_IP, Socket::IP_TOS] : [Socket::IPPROTO_IPV6, Socket::IPV6_TCLASS]
|
|
333
|
+
@sender.setsockopt(level, option, @options.traffic_class)
|
|
334
|
+
end
|
|
335
|
+
return unless @options.dont_fragment
|
|
336
|
+
|
|
337
|
+
if @family == :ipv6
|
|
338
|
+
@sender.setsockopt(Socket::IPPROTO_IPV6, Socket::IPV6_DONTFRAG, 1)
|
|
339
|
+
elsif @platform == :linux
|
|
340
|
+
# Linux IP_MTU_DISCOVER / IP_PMTUDISC_DO, not BSD IP_DONTFRAG.
|
|
341
|
+
@sender.setsockopt(Socket::IPPROTO_IP, 10, 2)
|
|
342
|
+
else
|
|
343
|
+
@sender.setsockopt(Socket::IPPROTO_IP, Socket::IP_DONTFRAG, 1)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def bind_interface(resource)
|
|
348
|
+
index = @interface_index || @scope_id
|
|
349
|
+
return unless index
|
|
350
|
+
|
|
351
|
+
if @platform == :linux
|
|
352
|
+
name = @interfaces.find { |entry| entry.ifindex == index }&.name
|
|
353
|
+
resource.setsockopt(Socket::SOL_SOCKET, Socket::SO_BINDTODEVICE, "#{name}\0")
|
|
354
|
+
else
|
|
355
|
+
level, option = @family == :ipv4 ? [Socket::IPPROTO_IP, 25] : [Socket::IPPROTO_IPV6, 125]
|
|
356
|
+
resource.setsockopt(level, option, index)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def open_receivers
|
|
361
|
+
if @platform == :linux && @options.protocol == :udp
|
|
362
|
+
@error_queue = true
|
|
363
|
+
level, option = @family == :ipv4 ? [Socket::IPPROTO_IP, LINUX_IP_RECVERR] : [Socket::IPPROTO_IPV6, LINUX_IPV6_RECVERR]
|
|
364
|
+
@sender.setsockopt(level, option, 1)
|
|
365
|
+
@readers[@sender] = :error_queue
|
|
366
|
+
return
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
receiver = socket(Socket::SOCK_RAW, icmp_protocol)
|
|
370
|
+
bind_interface(receiver)
|
|
371
|
+
receiver.bind(sockaddr(@source_address, 0, @scope_id))
|
|
372
|
+
receiver.setsockopt(Socket::IPPROTO_IPV6, Socket::IPV6_RECVPKTINFO, 1) if @family == :ipv6
|
|
373
|
+
@readers[receiver] = :icmp
|
|
374
|
+
return unless @options.protocol == :tcp
|
|
375
|
+
|
|
376
|
+
if @platform == :linux
|
|
377
|
+
open_linux_tcp_receiver
|
|
378
|
+
else
|
|
379
|
+
interfaces = @source_interfaces.map(&:name).uniq
|
|
380
|
+
# A route to an address assigned to this host is delivered through
|
|
381
|
+
# loopback, even when that address belongs to a physical interface.
|
|
382
|
+
local_target = local_addresses.any? do |entry|
|
|
383
|
+
IPAddr.new(entry.addr.ip_address.split("%", 2).first).to_s == @target.address
|
|
384
|
+
end
|
|
385
|
+
if local_target && !@interface
|
|
386
|
+
interfaces = @interfaces.select { |entry| (entry.flags & Socket::IFF_LOOPBACK) != 0 }.map(&:name).uniq
|
|
387
|
+
end
|
|
388
|
+
unless interfaces.length == 1
|
|
389
|
+
raise NetworkError.new(:invalid_interface, "TCP capture requires an unambiguous source interface",
|
|
390
|
+
details: { interfaces: interfaces })
|
|
391
|
+
end
|
|
392
|
+
loopback_capture = @interfaces.any? do |entry|
|
|
393
|
+
entry.name == interfaces.first && (entry.flags & Socket::IFF_LOOPBACK) != 0
|
|
394
|
+
end
|
|
395
|
+
@local_capture = loopback_capture && local_target && !@source_interfaces.empty?
|
|
396
|
+
@capture_interface_index = interface_index(interfaces.first)
|
|
397
|
+
@pcap = Pcap.new(interface: interfaces.first, source: @source_address, destination: @target.address)
|
|
398
|
+
@readers[@pcap.io] = :pcap
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def open_linux_tcp_receiver
|
|
403
|
+
ether_type = @family == :ipv4 ? 0x0800 : 0x86dd
|
|
404
|
+
network_protocol = [ether_type].pack("n").unpack1("S")
|
|
405
|
+
receiver = Socket.new(Socket::AF_PACKET, Socket::SOCK_DGRAM, network_protocol)
|
|
406
|
+
@resources << receiver
|
|
407
|
+
receiver.setsockopt(LINUX_SOL_PACKET, LINUX_PACKET_AUXDATA, 1)
|
|
408
|
+
@capture_interface_index = @interface_index || @scope_id || 0
|
|
409
|
+
# sockaddr_ll has a native family/index and a network-order protocol.
|
|
410
|
+
address = [Socket::AF_PACKET, ether_type, @capture_interface_index, 0, 0, 0, "\0" * 8].pack("SniSCCa8")
|
|
411
|
+
receiver.bind(address)
|
|
412
|
+
@capture_source = IPAddr.new(@target.address).hton
|
|
413
|
+
@capture_destination = IPAddr.new(@source_address).hton
|
|
414
|
+
@packet_receiver = LinuxPacketReceiver.new
|
|
415
|
+
@readers[receiver] = :packet
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def read_packet_socket(resource, replies, limit)
|
|
419
|
+
count = 0
|
|
420
|
+
limit.times do
|
|
421
|
+
message = @packet_receiver.read_nonblock(resource)
|
|
422
|
+
break if message == :wait_readable
|
|
423
|
+
|
|
424
|
+
received_at = Clock.now
|
|
425
|
+
count += 1
|
|
426
|
+
data, sender, flags, *controls = message
|
|
427
|
+
next if (flags & (Socket::MSG_TRUNC | Socket::MSG_CTRUNC)) != 0
|
|
428
|
+
|
|
429
|
+
link_address = sender.to_sockaddr
|
|
430
|
+
next unless link_address.bytesize >= 20 && link_address.getbyte(10).zero? # PACKET_HOST
|
|
431
|
+
incoming_interface = link_address.byteslice(4, 4).unpack1("i")
|
|
432
|
+
next if @capture_interface_index.positive? && incoming_interface != @capture_interface_index
|
|
433
|
+
next unless captured_endpoints_match?(data)
|
|
434
|
+
|
|
435
|
+
control = controls.find { |item| item.level == LINUX_SOL_PACKET && item.type == LINUX_PACKET_AUXDATA }
|
|
436
|
+
status = control && control.data.bytesize >= 20 ? control.data.unpack1("L") : 0
|
|
437
|
+
checksum_status = if (status & LINUX_CSUM_NOT_READY) != 0
|
|
438
|
+
:kernel_offload
|
|
439
|
+
elsif (status & LINUX_CSUM_VALID) != 0
|
|
440
|
+
:kernel_verified
|
|
441
|
+
end
|
|
442
|
+
reply = Packet.decode(data, family: @family, ip_header: true, protocol: :tcp,
|
|
443
|
+
checksum_offloaded: !checksum_status.nil?)
|
|
444
|
+
next unless reply && reply[:kind] == :tcp_reply
|
|
445
|
+
|
|
446
|
+
reply = reply.merge(checksum_status: checksum_status) if checksum_status
|
|
447
|
+
reply = with_interface_context(reply, interface_index: incoming_interface)
|
|
448
|
+
replies << reply.merge(received_at: received_at)
|
|
449
|
+
end
|
|
450
|
+
count
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def captured_endpoints_match?(data)
|
|
454
|
+
if @family == :ipv4
|
|
455
|
+
data.bytesize >= 20 && data.getbyte(0) >> 4 == 4 && data.getbyte(9) == 6 &&
|
|
456
|
+
data.byteslice(12, 4) == @capture_source && data.byteslice(16, 4) == @capture_destination
|
|
457
|
+
else
|
|
458
|
+
data.bytesize >= 40 && data.getbyte(0) >> 4 == 6 &&
|
|
459
|
+
data.byteslice(8, 16) == @capture_source && data.byteslice(24, 16) == @capture_destination
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def read_socket(resource, protocol, replies, limit)
|
|
464
|
+
count = 0
|
|
465
|
+
limit.times do
|
|
466
|
+
message = resource.recvmsg_nonblock(65_535, 0, 4096, exception: false)
|
|
467
|
+
break if message == :wait_readable
|
|
468
|
+
|
|
469
|
+
count += 1
|
|
470
|
+
data, sender, flags, *controls = message
|
|
471
|
+
next if (flags & Socket::MSG_TRUNC) != 0
|
|
472
|
+
|
|
473
|
+
received_at = Clock.now
|
|
474
|
+
data = normalize_darwin_ipv4(data) if @platform == :darwin && @family == :ipv4
|
|
475
|
+
reply = Packet.decode(data, family: @family, source: sender.ip_address.split("%", 2).first,
|
|
476
|
+
destination: @source_address, ip_header: @family == :ipv4, protocol: protocol)
|
|
477
|
+
if reply
|
|
478
|
+
packet_info = controls.find do |control|
|
|
479
|
+
control.level == Socket::IPPROTO_IPV6 && control.type == Socket::IPV6_PKTINFO && control.data.bytesize >= 20
|
|
480
|
+
end
|
|
481
|
+
incoming_interface = packet_info&.data&.byteslice(16, 4)&.unpack1("I")
|
|
482
|
+
reply = with_interface_context(reply, source: sender, interface_index: incoming_interface)
|
|
483
|
+
replies << reply.merge(received_at: received_at)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
count
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def read_error_queue(resource, replies, limit)
|
|
490
|
+
count = 0
|
|
491
|
+
limit.times do
|
|
492
|
+
message = resource.recvmsg_nonblock(65_535, LINUX_MSG_ERRQUEUE, 4096, exception: false)
|
|
493
|
+
break if message == :wait_readable
|
|
494
|
+
|
|
495
|
+
count += 1
|
|
496
|
+
data, destination, flags, *controls = message
|
|
497
|
+
next if (flags & (Socket::MSG_TRUNC | Socket::MSG_CTRUNC)) != 0
|
|
498
|
+
|
|
499
|
+
level, type = @family == :ipv4 ? [Socket::IPPROTO_IP, LINUX_IP_RECVERR] : [Socket::IPPROTO_IPV6, LINUX_IPV6_RECVERR]
|
|
500
|
+
control = controls.find { |item| item.level == level && item.type == type }
|
|
501
|
+
next unless control && control.data.bytesize >= 16
|
|
502
|
+
|
|
503
|
+
reply = decode_extended_error(control.data, destination, data)
|
|
504
|
+
replies << reply.merge(received_at: Clock.now) if reply
|
|
505
|
+
end
|
|
506
|
+
# Unexpected UDP application data must not leave select permanently
|
|
507
|
+
# readable. It does not prove receipt of any particular traceroute probe.
|
|
508
|
+
(limit - count).times do
|
|
509
|
+
begin
|
|
510
|
+
message = resource.recvmsg_nonblock(65_535, 0, 0, exception: false)
|
|
511
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EACCES, Errno::EMSGSIZE
|
|
512
|
+
# Read network failures through the extended queue. SO_ERROR may
|
|
513
|
+
# represent an error already decoded, or one arriving between the
|
|
514
|
+
# two reads; its queued record will be handled on the next poll.
|
|
515
|
+
break
|
|
516
|
+
end
|
|
517
|
+
break if message == :wait_readable
|
|
518
|
+
|
|
519
|
+
count += 1
|
|
520
|
+
end
|
|
521
|
+
count
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def decode_extended_error(data, destination, _payload)
|
|
525
|
+
errno, origin, type, code, _padding, info, = data.unpack("LCCCCLL")
|
|
526
|
+
if origin == 1
|
|
527
|
+
name = errno == Errno::EMSGSIZE::Errno ? :packet_too_large : :network_error
|
|
528
|
+
raise NetworkError.new(name, "Local socket error #{errno}", details: { errno: errno, mtu: info.zero? ? nil : info })
|
|
529
|
+
end
|
|
530
|
+
expected_origin = @family == :ipv4 ? 2 : 3
|
|
531
|
+
return unless origin == expected_origin && destination.ip?
|
|
532
|
+
|
|
533
|
+
probe = @udp_probes[destination.ip_port]
|
|
534
|
+
return unless probe && destination.ip_address.split("%", 2).first == @target.address
|
|
535
|
+
|
|
536
|
+
address_length = @family == :ipv4 ? 16 : 28
|
|
537
|
+
return unless data.bytesize >= 16 + address_length
|
|
538
|
+
|
|
539
|
+
offender = Addrinfo.new(data.byteslice(16, address_length))
|
|
540
|
+
return unless offender.ip?
|
|
541
|
+
|
|
542
|
+
kind = if @family == :ipv4
|
|
543
|
+
{ 3 => (code == 4 ? :packet_too_big : :unreachable), 11 => :time_exceeded, 12 => :parameter_problem }[type]
|
|
544
|
+
else
|
|
545
|
+
{ 1 => :unreachable, 2 => :packet_too_big, 3 => :time_exceeded, 4 => :parameter_problem }[type]
|
|
546
|
+
end
|
|
547
|
+
return unless kind
|
|
548
|
+
|
|
549
|
+
reply = {
|
|
550
|
+
kind: kind, family: @family, source: offender.ip_address.split("%", 2).first, destination: @source_address,
|
|
551
|
+
icmp_type: type, icmp_code: code, mtu: kind == :packet_too_big && info.positive? ? info : nil,
|
|
552
|
+
quoted: { family: @family, source: @source_address, destination: @target.address, protocol: :udp,
|
|
553
|
+
source_port: @source_port, destination_port: probe.destination_port }
|
|
554
|
+
}
|
|
555
|
+
with_interface_context(reply, source: offender, interface_index: @interface_index || @scope_id)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def with_interface_context(reply, source: nil, interface_index: nil)
|
|
559
|
+
context = {}
|
|
560
|
+
context[:interface_index] = interface_index if interface_index&.positive?
|
|
561
|
+
if @family == :ipv6 && reply[:source] && IPAddr.new(reply[:source]).link_local?
|
|
562
|
+
scope = source.to_sockaddr.byteslice(24, 4)&.unpack1("I") if source&.ipv6?
|
|
563
|
+
scope = interface_index || @scope_id if !scope || scope.zero?
|
|
564
|
+
context[:source_scope_id] = scope if scope&.positive?
|
|
565
|
+
end
|
|
566
|
+
reply.merge(context)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# BSD raw IPv4 reception exposes ip_len/ip_off in host byte order, and
|
|
570
|
+
# ip_len excludes the outer header. Packet codecs consume wire format.
|
|
571
|
+
def normalize_darwin_ipv4(data)
|
|
572
|
+
return data if data.bytesize < 20
|
|
573
|
+
|
|
574
|
+
length = (data.getbyte(0) & 0x0f) * 4
|
|
575
|
+
return data unless length >= 20 && length <= data.bytesize
|
|
576
|
+
|
|
577
|
+
normalized = data.dup
|
|
578
|
+
normalized[2, 2] = [data.bytesize].pack("n")
|
|
579
|
+
normalized[6, 2] = [data.byteslice(6, 2).unpack1("S")].pack("n")
|
|
580
|
+
restore_darwin_icmp_quote!(normalized, length)
|
|
581
|
+
normalized[10, 2] = "\0\0"
|
|
582
|
+
checksum = normalized.byteslice(0, length).unpack("n*").sum
|
|
583
|
+
checksum = (checksum & 0xffff) + (checksum >> 16) while checksum > 0xffff
|
|
584
|
+
normalized[10, 2] = [~checksum & 0xffff].pack("n")
|
|
585
|
+
normalized
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def restore_darwin_icmp_quote!(packet, header_length)
|
|
589
|
+
return unless packet.getbyte(9) == Socket::IPPROTO_ICMP
|
|
590
|
+
|
|
591
|
+
type = packet.getbyte(header_length)
|
|
592
|
+
code = packet.getbyte(header_length + 1)
|
|
593
|
+
delivered = case type
|
|
594
|
+
when 3 then code && (0..15).cover?(code)
|
|
595
|
+
when 11, 12 then code && (0..1).cover?(code)
|
|
596
|
+
else false
|
|
597
|
+
end
|
|
598
|
+
return unless delivered
|
|
599
|
+
|
|
600
|
+
offset = header_length + 8
|
|
601
|
+
return if packet.bytesize < offset + 28 || packet.getbyte(offset) >> 4 != 4
|
|
602
|
+
|
|
603
|
+
quoted_header_length = (packet.getbyte(offset) & 0x0f) * 4
|
|
604
|
+
return if quoted_header_length < 20 || packet.bytesize < offset + quoted_header_length + 8
|
|
605
|
+
|
|
606
|
+
# XNU icmp_input's deliver path applies NTOHS to the quoted ip_len
|
|
607
|
+
# before rip_input. The quote's ip_off remains in network order. Restore
|
|
608
|
+
# only that length; retain the original ICMP/quoted-header checksums so
|
|
609
|
+
# Packet still rejects damaged replies instead of blessing their bytes.
|
|
610
|
+
packet[offset + 2, 2] = [packet.byteslice(offset + 2, 2).unpack1("S")].pack("n")
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def network_error(error, replies: [])
|
|
614
|
+
if error.is_a?(NetworkError)
|
|
615
|
+
return error if replies.empty?
|
|
616
|
+
|
|
617
|
+
return NetworkError.new(error.code, error.message, details: error.details, replies: replies + error.replies)
|
|
618
|
+
end
|
|
619
|
+
code = case error
|
|
620
|
+
when Errno::EPERM, Errno::EACCES then :permission_denied
|
|
621
|
+
when Errno::EPROTONOSUPPORT, Errno::EAFNOSUPPORT, Errno::ENOPROTOOPT, Errno::EOPNOTSUPP then :unsupported_feature
|
|
622
|
+
when Errno::EMSGSIZE then :packet_too_large
|
|
623
|
+
when Errno::EADDRNOTAVAIL then :invalid_source
|
|
624
|
+
when Errno::ENETUNREACH, Errno::EHOSTUNREACH then :no_route
|
|
625
|
+
else :network_error
|
|
626
|
+
end
|
|
627
|
+
NetworkError.new(code, error.message,
|
|
628
|
+
details: { operation: error.class.name, protocol: @options.protocol, family: @family }, replies: replies)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Ruby's recvmsg adds MSG_CMSG_CLOEXEC unconditionally on Linux, while
|
|
632
|
+
# AF_PACKET rejects that flag with EINVAL. This small, nonblocking libc
|
|
633
|
+
# boundary preserves the kernel's PACKET_AUXDATA checksum evidence.
|
|
634
|
+
class LinuxPacketReceiver
|
|
635
|
+
DATA_SIZE = 65_575
|
|
636
|
+
CONTROL_SIZE = 4096
|
|
637
|
+
ADDRESS_SIZE = 128
|
|
638
|
+
|
|
639
|
+
def initialize
|
|
640
|
+
require "fiddle/import"
|
|
641
|
+
types = Module.new.extend(Fiddle::Importer)
|
|
642
|
+
iovec_type = types.struct(["void* iov_base", "size_t iov_len"])
|
|
643
|
+
message_type = types.struct([
|
|
644
|
+
"void* msg_name", "unsigned int msg_namelen", "void* msg_iov", "size_t msg_iovlen",
|
|
645
|
+
"void* msg_control", "size_t msg_controllen", "int msg_flags"
|
|
646
|
+
])
|
|
647
|
+
@iovec = iovec_type.malloc(Fiddle::RUBY_FREE)
|
|
648
|
+
@message = message_type.malloc(Fiddle::RUBY_FREE)
|
|
649
|
+
@data = Fiddle::Pointer.malloc(DATA_SIZE, Fiddle::RUBY_FREE)
|
|
650
|
+
@address = Fiddle::Pointer.malloc(ADDRESS_SIZE, Fiddle::RUBY_FREE)
|
|
651
|
+
@control = Fiddle::Pointer.malloc(CONTROL_SIZE, Fiddle::RUBY_FREE)
|
|
652
|
+
@recvmsg = Fiddle::Function.new(Fiddle::Handle::DEFAULT["recvmsg"],
|
|
653
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_SSIZE_T)
|
|
654
|
+
rescue LoadError => error
|
|
655
|
+
raise NetworkError.new(:unsupported_feature, "Linux TCP capture requires the fiddle gem", details: { cause: error.message })
|
|
656
|
+
rescue Fiddle::DLError => error
|
|
657
|
+
raise NetworkError.new(:unsupported_feature, "Cannot load recvmsg", details: { cause: error.message })
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def read_nonblock(socket)
|
|
661
|
+
@iovec.iov_base = @data
|
|
662
|
+
@iovec.iov_len = DATA_SIZE
|
|
663
|
+
@message.msg_name = @address
|
|
664
|
+
@message.msg_namelen = ADDRESS_SIZE
|
|
665
|
+
@message.msg_iov = @iovec.to_ptr
|
|
666
|
+
@message.msg_iovlen = 1
|
|
667
|
+
@message.msg_control = @control
|
|
668
|
+
@message.msg_controllen = CONTROL_SIZE
|
|
669
|
+
@message.msg_flags = 0
|
|
670
|
+
length = @recvmsg.call(socket.fileno, @message.to_ptr, Socket::MSG_DONTWAIT)
|
|
671
|
+
if length.negative?
|
|
672
|
+
errno = Fiddle.last_error
|
|
673
|
+
return :wait_readable if [Errno::EAGAIN::Errno, Errno::EWOULDBLOCK::Errno].include?(errno)
|
|
674
|
+
|
|
675
|
+
raise SystemCallError.new("recvmsg(2)", errno)
|
|
676
|
+
end
|
|
677
|
+
unless length <= DATA_SIZE && @message.msg_namelen <= ADDRESS_SIZE && @message.msg_controllen <= CONTROL_SIZE
|
|
678
|
+
raise NetworkError.new(:capture_error, "Packet socket returned an invalid buffer length")
|
|
679
|
+
end
|
|
680
|
+
sender = Addrinfo.new(@address[0, @message.msg_namelen])
|
|
681
|
+
controls = decode_controls(@control[0, @message.msg_controllen], sender.afamily)
|
|
682
|
+
[@data[0, length], sender, @message.msg_flags, *controls]
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
private
|
|
686
|
+
|
|
687
|
+
def decode_controls(data, family)
|
|
688
|
+
alignment = Fiddle::SIZEOF_SIZE_T
|
|
689
|
+
header_length = alignment + 8
|
|
690
|
+
offset = 0
|
|
691
|
+
controls = []
|
|
692
|
+
while offset + header_length <= data.bytesize
|
|
693
|
+
length = data.byteslice(offset, alignment).unpack1("J")
|
|
694
|
+
break if length < header_length || offset + length > data.bytesize
|
|
695
|
+
|
|
696
|
+
level, type = data.byteslice(offset + alignment, 8).unpack("ii")
|
|
697
|
+
payload = data.byteslice(offset + header_length, length - header_length)
|
|
698
|
+
controls << Socket::AncillaryData.new(family, level, type, payload)
|
|
699
|
+
offset += (length + alignment - 1) & ~(alignment - 1)
|
|
700
|
+
end
|
|
701
|
+
controls
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
end
|