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.
@@ -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