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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Tracepath
6
+ class Options
7
+ DEFAULTS = {
8
+ protocol: :udp, family: :auto, first_ttl: 1, max_hops: 30, probes: 3,
9
+ max_inflight: 3, timeout: 3.0, max_duration: 120.0, dns_timeout: 3.0,
10
+ reverse_dns: false, ptr_timeout: 1.0, send_interval: 0.05, port: nil,
11
+ source: nil, interface: nil, traffic_class: 0, packet_size: nil, dont_fragment: false
12
+ }.freeze
13
+ attr_reader(*DEFAULTS.keys)
14
+
15
+ def initialize(**options)
16
+ unknown = options.keys - DEFAULTS.keys
17
+ raise ArgumentError, "unknown options: #{unknown.join(', ')}" unless unknown.empty?
18
+ values = DEFAULTS.merge(options)
19
+ DEFAULTS.each_key { |key| instance_variable_set("@#{key}", Values.copy(values[key])) }
20
+ validate!
21
+ @port ||= protocol == :tcp ? 443 : (protocol == :udp ? 33_434 : nil)
22
+ if protocol == :udp && @port + (max_hops - first_ttl + 1) * probes - 1 > 65_535
23
+ raise ArgumentError, "UDP probe ports would exceed 65535"
24
+ end
25
+ freeze
26
+ end
27
+
28
+ def to_h = DEFAULTS.keys.to_h { |key| [key, public_send(key)] }
29
+
30
+ private
31
+
32
+ def validate!
33
+ raise ArgumentError, "protocol must be udp, icmp, or tcp" unless %i[udp icmp tcp].include?(protocol)
34
+ raise ArgumentError, "family must be auto, ipv4, or ipv6" unless %i[auto ipv4 ipv6].include?(family)
35
+ { first_ttl: 1..255, max_hops: 1..255, probes: 1..255, max_inflight: 1..255,
36
+ traffic_class: 0..255 }.each do |key, range|
37
+ value = public_send(key)
38
+ raise ArgumentError, "#{key} must be an integer in #{range}" unless value.is_a?(Integer) && range.cover?(value)
39
+ end
40
+ raise ArgumentError, "first_ttl must not exceed max_hops" if first_ttl > max_hops
41
+ %i[timeout max_duration dns_timeout ptr_timeout send_interval].each do |key|
42
+ value = public_send(key)
43
+ valid = value.is_a?(Numeric) && value.real? && value.finite? && (key == :send_interval ? value >= 0 : value > 0)
44
+ raise ArgumentError, "#{key} must be a finite #{key == :send_interval ? 'nonnegative' : 'positive'} number" unless valid
45
+ end
46
+ %i[reverse_dns dont_fragment].each do |key|
47
+ raise ArgumentError, "#{key} must be true or false" unless [true, false].include?(public_send(key))
48
+ end
49
+ if !port.nil? && (!port.is_a?(Integer) || !(1..65_535).cover?(port))
50
+ raise ArgumentError, "port must be an integer in 1..65535"
51
+ end
52
+ raise ArgumentError, "port does not apply to ICMP" if protocol == :icmp && port
53
+ if !packet_size.nil? && (!packet_size.is_a?(Integer) || !(28..65_535).cover?(packet_size))
54
+ raise ArgumentError, "packet_size must be an integer in 28..65535"
55
+ end
56
+ minimum_size = (family == :ipv6 ? 40 : 20) + (protocol == :tcp ? 20 : 8)
57
+ if packet_size && packet_size < minimum_size
58
+ raise ArgumentError, "packet_size must be at least #{minimum_size} for the selected protocol and family"
59
+ end
60
+ raise ArgumentError, "dont_fragment only applies to IPv4" if family == :ipv6 && dont_fragment
61
+ if !interface.nil? && (!interface.is_a?(String) || interface.empty? || interface.include?("\0"))
62
+ raise ArgumentError, "interface must be a nonempty interface name"
63
+ end
64
+ return if source.nil?
65
+ raise ArgumentError, "source must be a numeric local address" unless source.is_a?(String)
66
+ address = IPAddr.new(source.split("%", 2).first)
67
+ if (family == :ipv4 && !address.ipv4?) || (family == :ipv6 && !address.ipv6?)
68
+ raise ArgumentError, "source and family disagree"
69
+ end
70
+ rescue IPAddr::InvalidAddressError
71
+ raise ArgumentError, "source must be a numeric local address"
72
+ end
73
+ end
74
+
75
+ class Request
76
+ attr_reader :target, :options
77
+
78
+ def initialize(target, **options)
79
+ unless target.is_a?(String) && !target.empty? && !target.match?(/[\s\x00]/)
80
+ raise ArgumentError, "target must be a nonempty hostname or numeric address without whitespace"
81
+ end
82
+ unknown = options.keys - Options::DEFAULTS.keys
83
+ raise ArgumentError, "unknown options: #{unknown.join(', ')}" unless unknown.empty?
84
+ @target = target.dup.freeze
85
+ @options = Values.copy(options)
86
+ freeze
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module Tracepath
6
+ # Wire codecs and response correlation. All decoders treat received bytes as
7
+ # untrusted input and return nil for unsupported or incomplete packets.
8
+ module Packet
9
+ MAX_PACKET_SIZE = 65_575
10
+ MAX_EXTENSION_HEADERS = 16
11
+ VLAN_TYPES = [0x8100, 0x88a8, 0x9100].freeze
12
+ ICMP_ERROR_KINDS = %i[time_exceeded unreachable packet_too_big parameter_problem].freeze
13
+
14
+ class << self
15
+ # ICMPv6 raw sockets calculate the checksum using the selected source IP.
16
+ def icmp_echo(family:, identifier:, sequence:, payload: '')
17
+ validate_family!(family)
18
+ validate_uint!(identifier, 16, 'identifier')
19
+ validate_uint!(sequence, 16, 'sequence')
20
+ validate_payload!(payload, family == :ipv4 ? 65_507 : 65_527)
21
+
22
+ packet = [family == :ipv4 ? 8 : 128, 0, 0, identifier, sequence].pack('CCnnn') + payload.b
23
+ packet[2, 2] = [checksum(packet)].pack('n') if family == :ipv4
24
+ packet
25
+ end
26
+
27
+ # Returns a TCP segment; the socket supplies the IP header.
28
+ def tcp_syn(family:, source:, destination:, source_port:, destination_port:, sequence:, payload: '')
29
+ validate_family!(family)
30
+ validate_uint!(source_port, 16, 'source_port')
31
+ validate_uint!(destination_port, 16, 'destination_port')
32
+ validate_uint!(sequence, 32, 'sequence')
33
+ validate_payload!(payload, family == :ipv4 ? 65_495 : 65_515)
34
+
35
+ packet = [source_port, destination_port, sequence, 0, 0x50, 0x02, 65_535, 0, 0].pack('nnNNCCnnn') + payload.b
36
+ header = pseudoheader(family, source, destination, 6, packet.bytesize)
37
+ packet[16, 2] = [checksum(header + packet)].pack('n')
38
+ packet
39
+ end
40
+
41
+ # raw IPv6 sockets omit the IP header. In that case the caller supplies
42
+ # the recvmsg peer and local destination, and the socket's protocol.
43
+ # checksum_offloaded is exclusively for trusted receive metadata (for
44
+ # example Linux PACKET_AUXDATA), never inferred from packet contents.
45
+ # local_capture is a transport-verified loopback capture taken before
46
+ # checksum finalization; it cannot establish checksum validity.
47
+ def decode(data, family:, source: nil, destination: nil, ip_header: true, protocol: :icmp, checksum_offloaded: false, local_capture: false)
48
+ return unless data.is_a?(String) && data.bytesize <= MAX_PACKET_SIZE
49
+ return unless %i[ipv4 ipv6].include?(family)
50
+
51
+ data = data.b
52
+ if ip_header
53
+ envelope = decode_ip(data, family: family, local_capture: local_capture)
54
+ return unless envelope
55
+
56
+ source = envelope[:source]
57
+ destination = envelope[:destination]
58
+ number = envelope[:number]
59
+ payload = envelope[:payload]
60
+ else
61
+ return unless address_family?(source, family) && address_family?(destination, family)
62
+
63
+ source = address(source).to_s
64
+ destination = address(destination).to_s
65
+ number = protocol_number(protocol, family)
66
+ payload = data
67
+ end
68
+
69
+ reply = { family: family, source: source, destination: destination }
70
+ case number
71
+ when family == :ipv4 ? 1 : 58
72
+ decode_icmp(payload, reply)
73
+ when 6
74
+ decode_tcp(payload, reply, checksum_offloaded: checksum_offloaded, local_capture: local_capture)
75
+ end
76
+ rescue ArgumentError, TypeError
77
+ nil
78
+ end
79
+
80
+ # DLT values come from pcap_datalink, not the host's socket AF constants.
81
+ def decode_link(data, datalink:, local_capture: false)
82
+ return unless data.is_a?(String) && data.bytesize <= MAX_PACKET_SIZE + 64
83
+
84
+ case datalink
85
+ when 1 # DLT_EN10MB, including stacked 802.1Q / 802.1ad tags
86
+ return if data.bytesize < 14
87
+
88
+ offset = 14
89
+ ether_type = uint16(data, 12)
90
+ tags = 0
91
+ while VLAN_TYPES.include?(ether_type)
92
+ return if tags >= 4 || data.bytesize < offset + 4
93
+
94
+ ether_type = uint16(data, offset + 2)
95
+ offset += 4
96
+ tags += 1
97
+ end
98
+ family = { 0x0800 => :ipv4, 0x86dd => :ipv6 }[ether_type]
99
+ when 0, 108 # DLT_NULL (host endian), DLT_LOOP (network endian)
100
+ return if data.bytesize < 4
101
+
102
+ values = datalink == 108 ? [data.unpack1('N')] : [data.unpack1('V'), data.unpack1('N')]
103
+ af = values.find { |value| [2, 10, 24, 28, 30].include?(value) }
104
+ family = af == 2 ? :ipv4 : :ipv6 if af
105
+ offset = 4
106
+ when 12, 101 # DLT_RAW on different libpcap platforms
107
+ family = { 4 => :ipv4, 6 => :ipv6 }[data.getbyte(0).to_i >> 4]
108
+ offset = 0
109
+ when 228, 229 # DLT_IPV4, DLT_IPV6
110
+ family = datalink == 228 ? :ipv4 : :ipv6
111
+ offset = 0
112
+ else
113
+ return
114
+ end
115
+
116
+ decode(data.byteslice(offset..), family: family, local_capture: local_capture) if family
117
+ end
118
+
119
+ def match?(reply, probe)
120
+ return false unless reply.is_a?(Hash) && reply[:family] == probe.family
121
+ return false unless same_address?(reply[:destination], probe.source_address)
122
+
123
+ case reply[:kind]
124
+ when :echo_reply
125
+ probe.protocol == :icmp && same_address?(reply[:source], probe.destination_address) &&
126
+ icmp_identity_match?(reply, probe)
127
+ when :tcp_reply
128
+ tcp_match?(reply, probe)
129
+ when *ICMP_ERROR_KINDS
130
+ quote_match?(reply[:quoted], probe)
131
+ else
132
+ false
133
+ end
134
+ end
135
+
136
+ def destination_reached?(reply, probe)
137
+ return false unless match?(reply, probe)
138
+ return false unless same_address?(reply[:source], probe.destination_address)
139
+
140
+ case probe.protocol
141
+ when :icmp
142
+ reply[:kind] == :echo_reply
143
+ when :tcp
144
+ reply[:kind] == :tcp_reply
145
+ when :udp
146
+ reply[:kind] == :unreachable &&
147
+ [reply[:icmp_type], reply[:icmp_code]] == (probe.family == :ipv4 ? [3, 3] : [1, 4])
148
+ else
149
+ false
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def decode_ip(data, family:, quoted: false, local_capture: false)
156
+ family == :ipv4 ? decode_ipv4(data, quoted: quoted, local_capture: local_capture) : decode_ipv6(data, quoted: quoted)
157
+ end
158
+
159
+ def decode_ipv4(data, quoted:, local_capture: false)
160
+ return if data.bytesize < 20 || data.getbyte(0) >> 4 != 4
161
+
162
+ header_length = (data.getbyte(0) & 0x0f) * 4
163
+ total_length = uint16(data, 2)
164
+ return if header_length < 20 || total_length < header_length || data.bytesize < header_length
165
+ return if !quoted && total_length > data.bytesize
166
+ if !quoted && checksum(data.byteslice(0, header_length)) != 0
167
+ return unless local_capture == true && data.getbyte(9) == 6 && uint16(data, 10).zero?
168
+ end
169
+ # An outer response must be complete. A quoted first fragment can still
170
+ # identify a kernel-fragmented probe from its first eight payload bytes.
171
+ fragment = uint16(data, 6)
172
+ return unless (fragment & 0x1fff).zero?
173
+ return if !quoted && (fragment & 0x2000).positive?
174
+
175
+ limit = [total_length, data.bytesize].min
176
+ {
177
+ family: :ipv4,
178
+ source: IPAddr.new_ntoh(data.byteslice(12, 4)).to_s,
179
+ destination: IPAddr.new_ntoh(data.byteslice(16, 4)).to_s,
180
+ number: data.getbyte(9),
181
+ payload: data.byteslice(header_length, limit - header_length)
182
+ }
183
+ end
184
+
185
+ def decode_ipv6(data, quoted:)
186
+ return if data.bytesize < 40 || data.getbyte(0) >> 4 != 6
187
+
188
+ payload_length = uint16(data, 4)
189
+ return if payload_length.zero? # Jumbo Payload options are unsupported.
190
+
191
+ total_length = 40 + payload_length
192
+ return if !quoted && total_length > data.bytesize
193
+
194
+ limit = [total_length, data.bytesize].min
195
+ number = data.getbyte(6)
196
+ offset = 40
197
+ extension_count = 0
198
+ while [0, 43, 44, 51, 60].include?(number)
199
+ return if extension_count >= MAX_EXTENSION_HEADERS || offset + 2 > limit
200
+
201
+ length = case number
202
+ when 44
203
+ return if offset + 8 > limit
204
+ fragment = uint16(data, offset + 2)
205
+ return unless (fragment & 0xfff8).zero?
206
+ return if !quoted && (fragment & 1).positive?
207
+
208
+ 8
209
+ when 51
210
+ return if data.getbyte(offset + 1).zero?
211
+
212
+ (data.getbyte(offset + 1) + 2) * 4
213
+ else
214
+ (data.getbyte(offset + 1) + 1) * 8
215
+ end
216
+ return if offset + length > limit
217
+
218
+ number = data.getbyte(offset)
219
+ offset += length
220
+ extension_count += 1
221
+ end
222
+
223
+ {
224
+ family: :ipv6,
225
+ source: IPAddr.new_ntoh(data.byteslice(8, 16)).to_s,
226
+ destination: IPAddr.new_ntoh(data.byteslice(24, 16)).to_s,
227
+ number: number,
228
+ payload: data.byteslice(offset, limit - offset)
229
+ }
230
+ end
231
+
232
+ def decode_icmp(payload, reply)
233
+ return if payload.bytesize < 8
234
+
235
+ family = reply[:family]
236
+ check = family == :ipv4 ? payload : pseudoheader(family, reply[:source], reply[:destination], 58, payload.bytesize) + payload
237
+ return unless checksum(check).zero?
238
+
239
+ type = payload.getbyte(0)
240
+ code = payload.getbyte(1)
241
+ kind = icmp_kind(family, type, code)
242
+ return unless kind
243
+
244
+ reply = reply.merge(kind: kind, icmp_type: type, icmp_code: code)
245
+ if kind == :echo_reply
246
+ reply.merge(identifier: uint16(payload, 4), sequence: uint16(payload, 6))
247
+ else
248
+ quoted = decode_quote(payload.byteslice(8..), family)
249
+ return unless quoted
250
+
251
+ mtu = family == :ipv4 ? uint16(payload, 6) : uint32(payload, 4) if kind == :packet_too_big
252
+ reply.merge(quoted: quoted, mtu: mtu)
253
+ end
254
+ end
255
+
256
+ def icmp_kind(family, type, code)
257
+ if family == :ipv4
258
+ case type
259
+ when 0 then :echo_reply if code.zero?
260
+ when 3 then code == 4 ? :packet_too_big : :unreachable
261
+ when 11 then :time_exceeded if code <= 1
262
+ when 12 then :parameter_problem
263
+ end
264
+ else
265
+ case type
266
+ when 129 then :echo_reply if code.zero?
267
+ when 1 then :unreachable
268
+ when 2 then :packet_too_big if code.zero?
269
+ when 3 then :time_exceeded if code <= 1
270
+ when 4 then :parameter_problem
271
+ end
272
+ end
273
+ end
274
+
275
+ def decode_tcp(payload, reply, checksum_offloaded: false, local_capture: false)
276
+ return if payload.bytesize < 20
277
+
278
+ header_length = (payload.getbyte(12) >> 4) * 4
279
+ return if header_length < 20 || header_length > payload.bytesize
280
+
281
+ unless checksum_offloaded == true || local_capture == true
282
+ header = pseudoheader(reply[:family], reply[:source], reply[:destination], 6, payload.bytesize)
283
+ return unless checksum(header + payload).zero?
284
+ end
285
+
286
+ decoded = reply.merge(
287
+ kind: :tcp_reply,
288
+ source_port: uint16(payload, 0), destination_port: uint16(payload, 2),
289
+ tcp_sequence: uint32(payload, 4), tcp_ack: uint32(payload, 8), tcp_flags: payload.getbyte(13)
290
+ )
291
+ if local_capture == true
292
+ decoded[:checksum_status] = :unverified_local_capture
293
+ elsif checksum_offloaded == true
294
+ decoded[:checksum_status] = :kernel_offload
295
+ end
296
+ decoded
297
+ end
298
+
299
+ def decode_quote(data, family)
300
+ ip = decode_ip(data, family: family, quoted: true)
301
+ return unless ip && ip[:payload].bytesize >= 8
302
+
303
+ quote = ip.slice(:family, :source, :destination)
304
+ payload = ip[:payload]
305
+ case ip[:number]
306
+ when 17
307
+ return if uint16(payload, 4) < 8
308
+
309
+ quote.merge(protocol: :udp, source_port: uint16(payload, 0), destination_port: uint16(payload, 2))
310
+ when 6
311
+ quote.merge(protocol: :tcp, source_port: uint16(payload, 0), destination_port: uint16(payload, 2), tcp_sequence: uint32(payload, 4))
312
+ when family == :ipv4 ? 1 : 58
313
+ return unless payload.getbyte(0) == (family == :ipv4 ? 8 : 128) && payload.getbyte(1).zero?
314
+
315
+ quote.merge(protocol: :icmp, identifier: uint16(payload, 4), sequence: uint16(payload, 6))
316
+ end
317
+ end
318
+
319
+ def quote_match?(quote, probe)
320
+ return false unless quote.is_a?(Hash) && quote[:family] == probe.family && quote[:protocol] == probe.protocol
321
+ return false unless same_address?(quote[:source], probe.source_address) && same_address?(quote[:destination], probe.destination_address)
322
+
323
+ case probe.protocol
324
+ when :icmp
325
+ icmp_identity_match?(quote, probe)
326
+ when :udp
327
+ ports_match?(quote, probe.source_port, probe.destination_port)
328
+ when :tcp
329
+ ports_match?(quote, probe.source_port, probe.destination_port) &&
330
+ valid_uint?(quote[:tcp_sequence], 32) && quote[:tcp_sequence] == probe.tcp_sequence
331
+ else
332
+ false
333
+ end
334
+ end
335
+
336
+ def tcp_match?(reply, probe)
337
+ return false unless probe.protocol == :tcp && same_address?(reply[:source], probe.destination_address)
338
+ return false unless ports_match?(reply, probe.destination_port, probe.source_port)
339
+
340
+ flags = reply[:tcp_flags]
341
+ ack = reply[:tcp_ack]
342
+ return false unless valid_uint?(flags, 8) && valid_uint?(ack, 32) && valid_uint?(probe.tcp_sequence, 32)
343
+ return false unless (flags & 0x10).positive? && [0x02, 0x04].include?(flags & 0x06) && (flags & 0x01).zero?
344
+
345
+ payload_size = probe.respond_to?(:payload_size) ? probe.payload_size : 0
346
+ payload_size ||= 0
347
+ return false unless valid_uint?(payload_size, 16)
348
+
349
+ delta = (ack - probe.tcp_sequence) & 0xffff_ffff
350
+ delta.between?(1, 1 + payload_size)
351
+ end
352
+
353
+ def icmp_identity_match?(reply, probe)
354
+ valid_uint?(reply[:identifier], 16) && valid_uint?(reply[:sequence], 16) &&
355
+ reply[:identifier] == probe.identifier && reply[:sequence] == probe.sequence
356
+ end
357
+
358
+ def ports_match?(reply, source_port, destination_port)
359
+ valid_uint?(reply[:source_port], 16) && valid_uint?(reply[:destination_port], 16) &&
360
+ reply[:source_port] == source_port && reply[:destination_port] == destination_port
361
+ end
362
+
363
+ def protocol_number(protocol, family)
364
+ { icmp: family == :ipv4 ? 1 : 58, tcp: 6, udp: 17 }[protocol]
365
+ end
366
+
367
+ def checksum(data)
368
+ total = 0
369
+ offset = 0
370
+ while offset + 1 < data.bytesize
371
+ total += (data.getbyte(offset) << 8) | data.getbyte(offset + 1)
372
+ offset += 2
373
+ end
374
+ total += data.getbyte(offset) << 8 if offset < data.bytesize
375
+ total = (total & 0xffff) + (total >> 16) while total > 0xffff
376
+ (~total) & 0xffff
377
+ end
378
+
379
+ def pseudoheader(family, source, destination, protocol, length)
380
+ unless address_family?(source, family) && address_family?(destination, family)
381
+ raise ArgumentError, 'source and destination must match the address family'
382
+ end
383
+
384
+ addresses = address(source).hton + address(destination).hton
385
+ addresses + (family == :ipv4 ? [0, protocol, length].pack('CCn') : [length, protocol].pack('NN'))
386
+ end
387
+
388
+ def address(value)
389
+ IPAddr.new(value.to_s.split('%', 2).first)
390
+ end
391
+
392
+ def address_family?(value, family)
393
+ return false unless value
394
+
395
+ ip = address(value)
396
+ family == :ipv4 ? ip.ipv4? : ip.ipv6?
397
+ rescue ArgumentError
398
+ false
399
+ end
400
+
401
+ def same_address?(left, right)
402
+ left && right && address(left) == address(right) || false
403
+ rescue ArgumentError
404
+ false
405
+ end
406
+
407
+ def uint16(data, offset)
408
+ data.byteslice(offset, 2).unpack1('n')
409
+ end
410
+
411
+ def uint32(data, offset)
412
+ data.byteslice(offset, 4).unpack1('N')
413
+ end
414
+
415
+ def validate_family!(family)
416
+ raise ArgumentError, 'family must be :ipv4 or :ipv6' unless %i[ipv4 ipv6].include?(family)
417
+ end
418
+
419
+ def validate_uint!(value, bits, name)
420
+ raise ArgumentError, "#{name} must be an unsigned #{bits}-bit integer" unless valid_uint?(value, bits)
421
+ end
422
+
423
+ def valid_uint?(value, bits)
424
+ value.is_a?(Integer) && value.between?(0, (1 << bits) - 1)
425
+ end
426
+
427
+ def validate_payload!(payload, maximum)
428
+ raise ArgumentError, 'payload must be a String' unless payload.is_a?(String)
429
+ raise ArgumentError, "payload exceeds #{maximum} bytes" if payload.bytesize > maximum
430
+ end
431
+ end
432
+ end
433
+ end