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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "privilege_check"
5
+
6
+ module Tracepath
7
+ class CLI
8
+ # Only the executable opts into this process policy. No probes have been
9
+ # sent when prepare runs, so re-execution cannot replay a partial trace.
10
+ class Privileges
11
+ class Error < StandardError; end
12
+
13
+ def initialize(executable:, check: PrivilegeCheck.new, executor: Process.method(:exec),
14
+ uid: -> { Process.euid }, terminal: nil, sudo: "/usr/bin/sudo")
15
+ @executable = File.expand_path(executable)
16
+ @check, @executor, @uid, @sudo = check, executor, uid, sudo
17
+ @terminal = terminal || method(:controlling_terminal?)
18
+ end
19
+
20
+ def prepare(options:, arguments:, err:)
21
+ return if @uid.call.zero? || !@check.required?(options)
22
+
23
+ unless File.executable?(@sudo)
24
+ raise Error, "probe sockets need additional privileges, but sudo is unavailable; run with the required privileges or use --no-sudo"
25
+ end
26
+
27
+ interactive = @terminal.call
28
+ message = "tracepath: probe sockets require privileges; restarting with sudo"
29
+ message += " (non-interactive; no password prompt)" unless interactive
30
+ err.puts message
31
+ err.flush
32
+ @executor.call(*command(arguments, interactive: interactive))
33
+ rescue SystemCallError => error
34
+ raise Error, "could not start sudo (#{error.class}); run with the required privileges or use --no-sudo"
35
+ end
36
+
37
+ private
38
+
39
+ def command(arguments, interactive:)
40
+ # Preserve dependency selection, including bundle exec, without passing
41
+ # Ruby startup hooks or the user's entire environment into root Ruby.
42
+ environment = ENV.keys.grep(/\ABUNDLE_/).to_h { |name| [name, nil] }
43
+ environment.merge!("RUBYOPT" => nil, "RUBYLIB" => nil)
44
+ specifications = runtime_specifications
45
+ bootstrap = File.expand_path("elevated.rb", __dir__)
46
+ [environment, @sudo, *(interactive ? [] : ["-n"]), "--", "/usr/bin/env",
47
+ "GEM_HOME=#{Gem.dir}", "GEM_PATH=#{Gem.path.join(File::PATH_SEPARATOR)}",
48
+ RbConfig.ruby, "--disable=rubyopt", bootstrap, specifications.length.to_s,
49
+ *specifications, @executable, "--no-sudo", *arguments]
50
+ end
51
+
52
+ def runtime_specifications
53
+ seen = {}
54
+ paths = []
55
+ visit = lambda do |name|
56
+ specification = Gem.loaded_specs[name]
57
+ return unless specification && !seen[name]
58
+
59
+ seen[name] = true
60
+ specification.runtime_dependencies.each { |dependency| visit.call(dependency.name) }
61
+ paths << File.expand_path(specification.loaded_from)
62
+ end
63
+ # Runtime gem dependencies; the entrypoint loads tracepath itself from
64
+ # its own installation or checkout, not another installed version.
65
+ %w[fiddle json resolv].each { |name| visit.call(name) }
66
+ paths
67
+ end
68
+
69
+ def controlling_terminal?
70
+ File.open("/dev/tty", "r+") { |terminal| terminal.tty? }
71
+ rescue SystemCallError, IOError
72
+ false
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracepath
4
+ class CLI
5
+ # Keep console state separate from probe evidence. Replies can finish out of
6
+ # order; columns always follow probe order, and cancellation is not a timeout.
7
+ class TextOutput
8
+ # Hostnames and diagnostics may contain untrusted terminal controls.
9
+ # Escape only their display form; observations keep their original bytes.
10
+ def self.escape(value)
11
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
12
+ .gsub(/[[:cntrl:]]/) { |character| format('\\x%02x', character.ord) }
13
+ end
14
+
15
+ def initialize(out)
16
+ @out = out
17
+ end
18
+
19
+ def header(target, options, packet_size: nil)
20
+ destination = target.address ? "#{target.original} (#{target.address})" : target.original
21
+ size = packet_size ? ", #{packet_size} byte packets" : ""
22
+ @out.puts "tracepath to #{self.class.escape(destination)}, #{options.protocol.to_s.upcase}, #{options.max_hops} hops max#{size}"
23
+ @out.flush
24
+ end
25
+
26
+ def start_hop(ttl)
27
+ return if @ttl == ttl
28
+
29
+ close_hop
30
+ @ttl, @next_index, @last_address, @pending = ttl, 0, nil, {}
31
+ write(ttl.to_s.rjust(2))
32
+ end
33
+
34
+ def probe_finished(result)
35
+ start_hop(result.probe.ttl)
36
+ add_probe(result.probe.index, result)
37
+ end
38
+
39
+ def hop(hop)
40
+ # An unstarted, wholly cancelled hop has no observation to display.
41
+ return if !@ttl && hop.probes.all? { |probe| probe.status == :cancelled }
42
+
43
+ start_hop(hop.ttl)
44
+ hop.probes.each_with_index { |probe, index| add_probe(index, probe) }
45
+ close_hop
46
+ end
47
+
48
+ def close_hop
49
+ return unless @ttl
50
+
51
+ @out.puts
52
+ @out.flush
53
+ @ttl = nil
54
+ end
55
+
56
+ def footer(result)
57
+ close_hop
58
+ if result.stop_reason == :cancelled
59
+ @out.puts "Trace interrupted; #{format('%.3f', result.duration)} s"
60
+ else
61
+ address = result.target.address
62
+ @out.puts "#{result.stop_reason}#{address ? " (#{self.class.escape(address)})" : ''}; #{format('%.3f', result.duration)} s"
63
+ end
64
+ @out.flush
65
+ end
66
+
67
+ private
68
+
69
+ def add_probe(index, result)
70
+ return if index < @next_index
71
+
72
+ @pending[index] = result
73
+ while (probe = @pending.delete(@next_index))
74
+ render_probe(probe)
75
+ @next_index += 1
76
+ end
77
+ end
78
+
79
+ def render_probe(probe)
80
+ case probe.status
81
+ when :timed_out then write(" *")
82
+ when :cancelled then nil
83
+ when :send_error then write(" !send_error#{probe.error ? "(#{self.class.escape(probe.error.code)})" : ''}")
84
+ when :reply
85
+ if probe.address != @last_address
86
+ write("\n ") if @last_address
87
+ name = probe.hostname ? "#{Values.serialize_hostname(probe.hostname)} (#{probe.address})" : probe.address.to_s
88
+ write(" #{self.class.escape(name)}")
89
+ @last_address = probe.address
90
+ end
91
+ write(" #{format('%.3f', probe.rtt_ms)} ms#{reply_marker(probe.reply || {})}")
92
+ else
93
+ write(" (#{probe.status})")
94
+ end
95
+ end
96
+
97
+ def write(text)
98
+ @out.print(text)
99
+ @out.flush
100
+ end
101
+
102
+ def reply_marker(reply)
103
+ mtu = reply[:mtu] || reply["mtu"]
104
+ kind = reply[:kind] || reply["kind"]
105
+ return " !F#{mtu ? "(mtu=#{mtu})" : ''}" if mtu || %w[packet_too_big fragmentation_needed].include?(kind.to_s)
106
+
107
+ type = reply[:icmp_type] || reply["icmp_type"]
108
+ code = reply[:icmp_code] || reply["icmp_code"]
109
+ family = reply[:family] || reply["family"]
110
+ marker = if family.to_s == "ipv4" && type == 3
111
+ { 0 => "!N", 1 => "!H", 2 => "!P", 4 => "!F", 5 => "!S", 9 => "!X", 10 => "!X", 13 => "!X" }[code]
112
+ elsif family.to_s == "ipv6" && type == 1
113
+ { 0 => "!N", 1 => "!X", 2 => "!S", 3 => "!H", 5 => "!X", 6 => "!X" }[code]
114
+ elsif family.to_s == "ipv6" && type == 2
115
+ "!F"
116
+ end
117
+
118
+ marker ? " #{marker}" : ""
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "tracepath"
6
+ require_relative "cli/text_output"
7
+ require_relative "cli/privileges"
8
+
9
+ module Tracepath
10
+ # Process policy belongs here; loading the library never installs signal handlers.
11
+ class CLI
12
+ def initialize(argv, out: $stdout, err: $stderr, input: $stdin, runner: Tracepath, signals: Signal, privileges: nil)
13
+ @argv = argv.dup
14
+ @out = out
15
+ @err = err
16
+ @input = input
17
+ @runner = runner
18
+ @signals = signals
19
+ @privileges = privileges
20
+ @auto_sudo = true
21
+ @options = {}
22
+ @target_sources = []
23
+ @format = :text
24
+ @concurrency = 4
25
+ @rate_limit = 50.0
26
+ @interrupted = false
27
+ end
28
+
29
+ def run
30
+ parser = build_parser
31
+ parser.order!(@argv) { |target| @target_sources << [:target, target] }
32
+ # OptionParser leaves arguments after -- in argv, without yielding them.
33
+ @argv.each { |target| @target_sources << [:target, target] }
34
+ @argv.clear
35
+ return 0 if @early_exit
36
+
37
+ targets = read_targets
38
+ raise OptionParser::MissingArgument, "HOST or --targets-file is required" if targets.empty?
39
+
40
+ validate_batch_options!
41
+ settings = Options.new(**@options)
42
+ targets.each { |target| Request.new(target) }
43
+ if @privileges && @auto_sudo
44
+ @privileges.prepare(options: settings, arguments: elevation_arguments(targets), err: @err)
45
+ end
46
+ token = CancellationToken.new
47
+ previous_handler = @signals.trap("INT") do
48
+ @interrupted = true
49
+ token.cancel
50
+ end
51
+ handler_installed = true
52
+ @options[:cancellation] = token
53
+
54
+ results = targets.one? ? run_single(targets.first) : run_batch(targets)
55
+ return 130 if @interrupted || results.any? { |result| result.stop_reason == :cancelled }
56
+ return 2 if results.any? { |result| result.error || result.stop_reason == :error }
57
+
58
+ results.all?(&:reached?) ? 0 : 1
59
+ rescue OptionParser::ParseError, ArgumentError => error
60
+ @text_output&.close_hop
61
+ @err.puts TextOutput.escape("tracepath: #{error.message}")
62
+ @err.puts "Try 'tracepath --help' for usage."
63
+ 2
64
+ rescue Privileges::Error => error
65
+ @err.puts TextOutput.escape("tracepath: #{error.message}")
66
+ 2
67
+ rescue StandardError => error
68
+ @text_output&.close_hop
69
+ @err.puts TextOutput.escape("tracepath: #{error.class}: #{error.message}")
70
+ @interrupted ? 130 : 2
71
+ ensure
72
+ @signals.trap("INT", previous_handler) if handler_installed
73
+ end
74
+
75
+ private
76
+
77
+ def build_parser
78
+ OptionParser.new do |parser|
79
+ parser.banner = "Usage: tracepath [options] HOST [HOST ...]"
80
+ parser.separator "Native IPv4/IPv6 traceroute. Multiple targets use a bounded worker pool."
81
+ parser.on("-4", "--ipv4", "Use IPv4") { @options[:family] = :ipv4 }
82
+ parser.on("-6", "--ipv6", "Use IPv6") { @options[:family] = :ipv6 }
83
+ parser.on("--family FAMILY", %w[auto ipv4 ipv6], "Address family (default: auto)") { |value| @options[:family] = value.to_sym }
84
+ parser.on("-U", "--udp", "Use UDP (default)") { @options[:protocol] = :udp }
85
+ parser.on("-I", "--icmp", "Use ICMP echo") { @options[:protocol] = :icmp }
86
+ parser.on("-T", "--tcp", "Use TCP SYN") { @options[:protocol] = :tcp }
87
+ parser.on("--protocol PROTOCOL", %w[udp icmp tcp], "Probe protocol") { |value| @options[:protocol] = value.to_sym }
88
+ parser.on("-p", "--port PORT", Integer, "Destination/base port (UDP: 33434; TCP: 443)") { |value| @options[:port] = value }
89
+ parser.on("-f", "--first-ttl TTL", Integer, "First TTL/hop limit (default: 1)") { |value| @options[:first_ttl] = value }
90
+ parser.on("-m", "--max-hops TTL", Integer, "Last TTL/hop limit (default: 30)") { |value| @options[:max_hops] = value }
91
+ parser.on("-q", "--probes COUNT", Integer, "Probes per hop (default: 3)") { |value| @options[:probes] = value }
92
+ parser.on("--max-inflight COUNT", Integer, "Outstanding probes per target (default: 3)") { |value| @options[:max_inflight] = value }
93
+ parser.on("-w", "--timeout SECONDS", Float, "Reply timeout per probe (default: 3)") { |value| @options[:timeout] = value }
94
+ parser.on("--max-duration SECONDS", Float, "Deadline per target (default: 120)") { |value| @options[:max_duration] = value }
95
+ parser.on("--dns-timeout SECONDS", Float, "Forward DNS timeout (default: 3)") { |value| @options[:dns_timeout] = value }
96
+ parser.on("--reverse-dns", "Resolve responder names (off by default)") { @options[:reverse_dns] = true }
97
+ parser.on("-n", "--numeric", "Show numeric addresses without reverse DNS") { @options[:reverse_dns] = false }
98
+ parser.on("--ptr-timeout SECONDS", Float, "Reverse DNS timeout (default: 1)") { |value| @options[:ptr_timeout] = value }
99
+ parser.on("-z", "--send-interval SECONDS", Float, "Minimum probe spacing per target (default: 0.05)") { |value| @options[:send_interval] = value }
100
+ parser.on("-s", "--source ADDRESS", "Bind the source address") { |value| @options[:source] = value }
101
+ parser.on("-i", "--interface NAME", "Bind the outgoing interface") { |value| @options[:interface] = value }
102
+ parser.on("--traffic-class NUMBER", Integer, "IPv4 TOS / IPv6 traffic class (default: 0)") { |value| @options[:traffic_class] = value }
103
+ parser.on("--packet-size BYTES", Integer, "IP packet size in bytes") { |value| @options[:packet_size] = value }
104
+ parser.on("-F", "--dont-fragment", "Request no fragmentation") { @options[:dont_fragment] = true }
105
+ parser.on("--concurrency COUNT", Integer, "Active batch targets (default: 4)") { |value| @concurrency = value }
106
+ parser.on("--rate-limit PPS", Float, "Probe rate, shared across batch targets (default: 50)") { |value| @rate_limit = value }
107
+ parser.on("--no-sudo", "Disable automatic CLI privilege elevation") { @auto_sudo = false }
108
+ parser.on("--targets-file FILE", "Read one target per line; '-' reads standard input") { |value| @target_sources << [:file, value] }
109
+ parser.on("--json", "Write one final JSON result/batch") { select_format!(:json) }
110
+ parser.on("--jsonl", "Write {index, result} JSON lines as targets finish") { select_format!(:jsonl) }
111
+ parser.on("-h", "--help", "Show this help") do
112
+ @out.puts parser
113
+ @early_exit = true
114
+ end
115
+ parser.on("--version", "Show version") do
116
+ @out.puts "tracepath #{Tracepath::VERSION}"
117
+ @early_exit = true
118
+ end
119
+ end
120
+ end
121
+
122
+ def select_format!(format)
123
+ raise OptionParser::InvalidOption, "--json and --jsonl cannot be combined" if @format != :text && @format != format
124
+
125
+ @format = format
126
+ end
127
+
128
+ def read_targets
129
+ @target_sources.flat_map do |kind, value|
130
+ next [value] if kind == :target
131
+
132
+ if value == "-"
133
+ target_lines(@input)
134
+ else
135
+ File.open(value, "r:utf-8") { |file| target_lines(file) }
136
+ end
137
+ end
138
+ end
139
+
140
+ def target_lines(input)
141
+ input.each_line.filter_map do |line|
142
+ target = line.strip
143
+ target unless target.empty? || target.start_with?("#")
144
+ end
145
+ end
146
+
147
+ def validate_batch_options!
148
+ raise ArgumentError, "concurrency must be in 1..256" unless (1..256).cover?(@concurrency)
149
+ raise ArgumentError, "rate_limit must be finite and positive" unless @rate_limit.finite? && @rate_limit.positive?
150
+ end
151
+
152
+ def elevation_arguments(targets)
153
+ arguments = @options.flat_map do |name, value|
154
+ case name
155
+ when :reverse_dns then [value ? "--reverse-dns" : "--numeric"]
156
+ when :dont_fragment then value ? ["--dont-fragment"] : []
157
+ else ["--#{name.to_s.tr('_', '-')}", value.to_s]
158
+ end
159
+ end
160
+ arguments.concat(["--concurrency", @concurrency.to_s, "--rate-limit", @rate_limit.to_s])
161
+ arguments << "--#{@format}" unless @format == :text
162
+ # stdin and target files have already been read as the original user.
163
+ # Passing the validated targets also prevents a second file read as root.
164
+ arguments.concat(["--", *targets])
165
+ end
166
+
167
+ def run_single(target)
168
+ rendered_hops = {}
169
+ progress = @format == :text ? { on_event: method(:render_event) } : {}
170
+ result = @runner.trace(target, rate_limit: @rate_limit, **progress, **@options) do |hop|
171
+ next unless @format == :text && @header_rendered
172
+
173
+ render_hop(hop)
174
+ rendered_hops[hop.ttl] = true
175
+ end
176
+ case @format
177
+ when :json then @out.puts JSON.generate(result.to_h)
178
+ when :jsonl then render_json_line(result, 0)
179
+ else
180
+ unless @header_rendered
181
+ text_output.header(result.target, Options.new(**@options.except(:cancellation)))
182
+ end
183
+ result.hops.each { |hop| render_hop(hop) unless rendered_hops[hop.ttl] }
184
+ render_footer(result)
185
+ end
186
+ render_error(result)
187
+ [result]
188
+ end
189
+
190
+ def run_batch(targets)
191
+ rendered = {}
192
+ batch = @runner.trace_many(targets, concurrency: @concurrency, rate_limit: @rate_limit, **@options) do |result, index|
193
+ render_batch_result(result, index, targets.length)
194
+ rendered[index] = true
195
+ end
196
+ batch.results.each_with_index do |result, index|
197
+ render_batch_result(result, index, targets.length) unless rendered[index]
198
+ end
199
+ if @format == :json
200
+ @out.puts JSON.generate(batch.to_h)
201
+ elsif @format == :text
202
+ counts = batch.counts.map { |reason, count| "#{reason}=#{count}" }.join(" ")
203
+ @out.puts "batch: #{counts}; #{format('%.3f', batch.duration)} s"
204
+ end
205
+ batch.results
206
+ end
207
+
208
+ def render_batch_result(result, index, total)
209
+ if @format == :jsonl
210
+ render_json_line(result, index)
211
+ elsif @format == :text
212
+ @out.puts "[#{index + 1}/#{total}] #{TextOutput.escape(result.target.original)}"
213
+ result.hops.each { |hop| render_hop(hop) }
214
+ render_footer(result)
215
+ end
216
+ render_error(result)
217
+ end
218
+
219
+ def render_json_line(result, index)
220
+ @out.puts JSON.generate(index: index, result: result.to_h)
221
+ @out.flush
222
+ end
223
+
224
+ def render_hop(hop)
225
+ text_output.hop(hop)
226
+ end
227
+
228
+ def render_event(event)
229
+ case event.fetch(:type)
230
+ when :trace_started
231
+ text_output.header(event.fetch(:target), event.fetch(:options), packet_size: event.fetch(:packet_size))
232
+ @header_rendered = true
233
+ when :hop_started
234
+ text_output.start_hop(event.fetch(:ttl))
235
+ when :probe_finished
236
+ # PTR resolution stays outside the receive loop. Named output is
237
+ # rendered once the completed Hop contains its resolved names.
238
+ text_output.probe_finished(event.fetch(:result)) unless @options[:reverse_dns]
239
+ end
240
+ end
241
+
242
+ def text_output = (@text_output ||= TextOutput.new(@out))
243
+
244
+ def render_footer(result)
245
+ text_output.footer(result)
246
+ end
247
+
248
+ def render_error(result)
249
+ return unless result.error
250
+
251
+ @err.puts TextOutput.escape("tracepath: #{result.target.original}: #{result.error.code}: #{result.error.message}")
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracepath
4
+ # Only expected environmental failures use this exception. Programming errors
5
+ # and exceptions raised by callers must retain their original identity.
6
+ class NetworkError < StandardError
7
+ attr_reader :code, :details, :replies
8
+
9
+ def initialize(code, message, details: {}, replies: [])
10
+ @code = code.to_sym
11
+ @details = details
12
+ # A later I/O failure must not discard observations already collected.
13
+ @replies = replies.dup.freeze
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ module Clock
19
+ def self.now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ end
21
+
22
+ class CancellationToken
23
+ def initialize(parent: nil)
24
+ @parent = parent
25
+ @cancelled = false
26
+ end
27
+
28
+ # Deliberately trap-safe: no mutex, allocation, or network operation.
29
+ def cancel = (@cancelled = true)
30
+
31
+ def cancelled? = @cancelled || !!@parent&.cancelled?
32
+ end
33
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Tracepath
7
+ module Values
8
+ def self.copy(value)
9
+ case value
10
+ when Hash then value.to_h { |key, item| [copy(key), copy(item)] }.freeze
11
+ when Array then value.map { |item| copy(item) }.freeze
12
+ when String, Time then value.dup.freeze
13
+ else value
14
+ end
15
+ end
16
+
17
+ def self.serialize(value)
18
+ case value
19
+ when Hash then value.to_h { |key, item| [key, serialize(item)] }
20
+ when Array then value.map { |item| serialize(item) }
21
+ when Time then value.iso8601(6)
22
+ else value.respond_to?(:to_h) && !value.nil? ? value.to_h : value
23
+ end
24
+ end
25
+
26
+ # DNS labels are octets, not necessarily UTF-8. Decimal escapes retain
27
+ # invalid octets; escaping literal backslashes makes that form reversible.
28
+ def self.serialize_hostname(hostname)
29
+ return if hostname.nil?
30
+
31
+ hostname.b.gsub("\\") { "\\092" }.force_encoding(Encoding::UTF_8).scrub do |bytes|
32
+ bytes.bytes.map { |byte| format("\\%03d", byte) }.join
33
+ end
34
+ end
35
+
36
+ def to_h = self.class.members.to_h { |member| [member, Values.serialize(public_send(member))] }
37
+
38
+ def to_json(*args) = to_h.to_json(*args)
39
+ end
40
+
41
+ ErrorInfo = Data.define(:code, :message, :details) do
42
+ include Values
43
+
44
+ def initialize(code:, message:, details: {})
45
+ super(code: code, message: Values.copy(message), details: Values.copy(details))
46
+ end
47
+
48
+ def self.from(error) = new(code: error.code, message: error.message, details: error.details)
49
+ end
50
+
51
+ Target = Data.define(:original, :address, :family, :scope_id) do
52
+ include Values
53
+
54
+ def initialize(original:, address: nil, family: nil, scope_id: nil)
55
+ super(original: Values.copy(original), address: Values.copy(address), family: family, scope_id: scope_id)
56
+ end
57
+ end
58
+
59
+ Probe = Data.define(:family, :protocol, :source_address, :destination_address,
60
+ :source_port, :destination_port, :identifier, :sequence, :tcp_sequence,
61
+ :ttl, :index, :sent_at, :deadline, :payload_size) do
62
+ include Values
63
+
64
+ def initialize(family:, protocol:, source_address:, destination_address:, ttl:, index:,
65
+ sent_at:, deadline:, source_port: nil, destination_port: nil, identifier: nil,
66
+ sequence: nil, tcp_sequence: nil, payload_size: 0)
67
+ super(family: family, protocol: protocol, source_address: Values.copy(source_address),
68
+ destination_address: Values.copy(destination_address), source_port: source_port,
69
+ destination_port: destination_port, identifier: identifier, sequence: sequence,
70
+ tcp_sequence: tcp_sequence, ttl: ttl, index: index, sent_at: sent_at,
71
+ deadline: deadline, payload_size: payload_size)
72
+ end
73
+ end
74
+
75
+ ProbeResult = Data.define(:probe, :status, :reply, :rtt, :error, :hostname) do
76
+ include Values
77
+
78
+ def initialize(probe:, status:, reply: nil, rtt: nil, error: nil, hostname: nil)
79
+ super(probe: probe, status: status, reply: Values.copy(reply), rtt: rtt,
80
+ error: error, hostname: Values.copy(hostname))
81
+ end
82
+
83
+ def address = reply&.fetch(:source, nil)
84
+
85
+ def rtt_ms = rtt && rtt * 1_000.0
86
+
87
+ def replied? = status == :reply
88
+
89
+ def to_h = super.merge(hostname: Values.serialize_hostname(hostname))
90
+ end
91
+
92
+ Hop = Data.define(:ttl, :probes) do
93
+ include Values
94
+
95
+ def initialize(ttl:, probes:)
96
+ super(ttl: ttl, probes: probes.dup.freeze)
97
+ end
98
+
99
+ def addresses = probes.filter_map(&:address).uniq.freeze
100
+
101
+ def rtts = probes.filter_map(&:rtt)
102
+
103
+ def rtt_min = rtts.min
104
+
105
+ def rtt_max = rtts.max
106
+
107
+ def rtt_avg = (values = rtts).empty? ? nil : values.sum / values.size
108
+
109
+ # Cancelled and unsent attempts are not evidence of packet loss.
110
+ def response_rate
111
+ completed = probes.count { |probe| %i[reply timed_out].include?(probe.status) }
112
+ completed.zero? ? nil : probes.count(&:replied?).fdiv(completed)
113
+ end
114
+ end
115
+
116
+ Result = Data.define(:target, :options, :hops, :started_at, :duration, :stop_reason, :error) do
117
+ include Values
118
+
119
+ def initialize(target:, options:, hops: [], started_at:, duration:, stop_reason:, error: nil)
120
+ super(target: target, options: options, hops: hops.dup.freeze,
121
+ started_at: Values.copy(started_at), duration: duration, stop_reason: stop_reason, error: error)
122
+ end
123
+
124
+ def reached? = stop_reason == :reached
125
+ end
126
+
127
+ BatchResult = Data.define(:results, :duration) do
128
+ include Values
129
+
130
+ def initialize(results:, duration:)
131
+ super(results: results.dup.freeze, duration: duration)
132
+ end
133
+
134
+ def counts = results.each_with_object(Hash.new(0)) { |result, counts| counts[result.stop_reason] += 1 }.freeze
135
+
136
+ def to_h = super.merge(counts: counts)
137
+ end
138
+ end