tracepath 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +221 -0
- data/docs/platforms.md +94 -0
- data/docs/releasing.md +16 -0
- data/docs/verification.md +116 -0
- data/exe/tracepath +8 -0
- data/lib/tracepath/cli/elevated.rb +27 -0
- data/lib/tracepath/cli/privilege_check.rb +65 -0
- data/lib/tracepath/cli/privileges.rb +76 -0
- data/lib/tracepath/cli/text_output.rb +122 -0
- data/lib/tracepath/cli.rb +254 -0
- data/lib/tracepath/errors.rb +33 -0
- data/lib/tracepath/models.rb +138 -0
- data/lib/tracepath/options.rb +89 -0
- data/lib/tracepath/packet.rb +433 -0
- data/lib/tracepath/pcap.rb +219 -0
- data/lib/tracepath/resolver.rb +82 -0
- data/lib/tracepath/runner.rb +248 -0
- data/lib/tracepath/transport.rb +705 -0
- data/lib/tracepath/version.rb +5 -0
- data/lib/tracepath.rb +110 -0
- metadata +126 -0
|
@@ -0,0 +1,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
|