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,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