lanet 0.3.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +19 -0
- data/Gemfile.lock +1 -1
- data/README.md +109 -0
- data/index.html +363 -107
- data/lib/lanet/cli.rb +145 -0
- data/lib/lanet/file_transfer.rb +8 -1
- data/lib/lanet/mesh.rb +493 -0
- data/lib/lanet/traceroute.rb +426 -0
- data/lib/lanet/version.rb +1 -1
- data/lib/lanet.rb +12 -0
- metadata +4 -2
@@ -0,0 +1,426 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "socket"
|
4
|
+
require "timeout"
|
5
|
+
require "resolv"
|
6
|
+
|
7
|
+
module Lanet
|
8
|
+
class Traceroute
|
9
|
+
# Supported protocols
|
10
|
+
PROTOCOLS = %i[icmp udp tcp].freeze
|
11
|
+
|
12
|
+
# Default settings
|
13
|
+
DEFAULT_MAX_HOPS = 30
|
14
|
+
DEFAULT_TIMEOUT = 1
|
15
|
+
DEFAULT_QUERIES = 3
|
16
|
+
DEFAULT_PORT = 33_434 # Starting port for UDP traceroute
|
17
|
+
|
18
|
+
attr_reader :results, :protocol, :max_hops, :timeout, :queries
|
19
|
+
|
20
|
+
def initialize(protocol: :udp, max_hops: DEFAULT_MAX_HOPS, timeout: DEFAULT_TIMEOUT, queries: DEFAULT_QUERIES)
|
21
|
+
@protocol = protocol.to_sym
|
22
|
+
@max_hops = max_hops
|
23
|
+
@timeout = timeout
|
24
|
+
@queries = queries
|
25
|
+
@results = []
|
26
|
+
|
27
|
+
return if PROTOCOLS.include?(@protocol)
|
28
|
+
|
29
|
+
raise ArgumentError, "Protocol must be one of #{PROTOCOLS.join(", ")}"
|
30
|
+
end
|
31
|
+
|
32
|
+
def trace(destination)
|
33
|
+
@results = []
|
34
|
+
destination_ip = resolve_destination(destination)
|
35
|
+
|
36
|
+
begin
|
37
|
+
case @protocol
|
38
|
+
when :icmp
|
39
|
+
trace_icmp(destination_ip)
|
40
|
+
when :udp
|
41
|
+
trace_udp(destination_ip)
|
42
|
+
when :tcp
|
43
|
+
trace_tcp(destination_ip)
|
44
|
+
end
|
45
|
+
rescue StandardError => e
|
46
|
+
raise e unless e.message.include?("Must run as root/administrator")
|
47
|
+
|
48
|
+
# Fall back to system traceroute command if we don't have root privileges
|
49
|
+
trace_using_system_command(destination)
|
50
|
+
end
|
51
|
+
|
52
|
+
@results
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def trace_using_system_command(destination)
|
58
|
+
# Build the appropriate system traceroute command
|
59
|
+
system_cmd = case @protocol
|
60
|
+
when :icmp
|
61
|
+
"traceroute -I"
|
62
|
+
when :tcp
|
63
|
+
"traceroute -T"
|
64
|
+
else
|
65
|
+
"traceroute" # UDP is the default for most traceroute commands
|
66
|
+
end
|
67
|
+
|
68
|
+
# Add options for max hops, timeout, and queries/retries
|
69
|
+
system_cmd += " -m #{@max_hops} -w #{@timeout} -q #{@queries} #{destination}"
|
70
|
+
|
71
|
+
# Execute the command and capture output
|
72
|
+
output = `#{system_cmd}`
|
73
|
+
|
74
|
+
# Parse the output to build our results
|
75
|
+
parse_system_traceroute_output(output)
|
76
|
+
end
|
77
|
+
|
78
|
+
def parse_system_traceroute_output(output)
|
79
|
+
lines = output.split("\n")
|
80
|
+
|
81
|
+
# Skip only the header line which typically starts with "traceroute to..."
|
82
|
+
lines.shift if lines.any? && lines.first.start_with?("traceroute to")
|
83
|
+
|
84
|
+
# Process each line of output
|
85
|
+
lines.each do |line|
|
86
|
+
# Extract hop number and details
|
87
|
+
next unless line =~ /^\s*(\d+)\s+(.+)$/
|
88
|
+
|
89
|
+
hop_num = Regexp.last_match(1).to_i
|
90
|
+
hop_details = Regexp.last_match(2)
|
91
|
+
|
92
|
+
# Parse the hop details
|
93
|
+
hostname = nil
|
94
|
+
avg_time = nil
|
95
|
+
|
96
|
+
# Check for timeout indicated by asterisks
|
97
|
+
if ["* * *", "*"].include?(hop_details.strip)
|
98
|
+
# All timeouts at this hop
|
99
|
+
@results << { ttl: hop_num, ip: nil, hostname: nil, avg_time: nil, timeouts: @queries }
|
100
|
+
next
|
101
|
+
end
|
102
|
+
|
103
|
+
# Extract all IPs from the hop details to support load-balancing detection
|
104
|
+
all_ips = extract_multiple_ips(hop_details)
|
105
|
+
|
106
|
+
# Extract the first IP (primary IP for this hop)
|
107
|
+
ip = all_ips&.first
|
108
|
+
|
109
|
+
# Try to extract hostname if present
|
110
|
+
# Format: "hostname (ip)"
|
111
|
+
if hop_details =~ /([^\s(]+)\s+\(([0-9.]+)\)/
|
112
|
+
hostname = Regexp.last_match(1)
|
113
|
+
# We already have the IP from all_ips, so no need to set it again
|
114
|
+
end
|
115
|
+
|
116
|
+
# Extract response times - typically format is "X.XXX ms Y.YYY ms Z.ZZZ ms"
|
117
|
+
times = hop_details.scan(/(\d+\.\d+)\s*ms/).flatten.map(&:to_f)
|
118
|
+
avg_time = (times.sum / times.size).round(2) if times.any?
|
119
|
+
|
120
|
+
# Add to results
|
121
|
+
@results << {
|
122
|
+
ttl: hop_num,
|
123
|
+
ip: ip,
|
124
|
+
hostname: hostname,
|
125
|
+
avg_time: avg_time,
|
126
|
+
timeouts: @queries - times.size,
|
127
|
+
# Include all IPs if there are multiple
|
128
|
+
all_ips: all_ips&.size && all_ips.size > 1 ? all_ips : nil
|
129
|
+
}
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def extract_multiple_ips(hop_details)
|
134
|
+
# Match all IP addresses in the hop details
|
135
|
+
ips = hop_details.scan(/\b(?:\d{1,3}\.){3}\d{1,3}\b/).uniq
|
136
|
+
|
137
|
+
# If no IPs were found in a non-timeout line, there might be a special format
|
138
|
+
if ips.empty? && !hop_details.include?("*")
|
139
|
+
# Try to find any IP-like patterns (some traceroute outputs format differently)
|
140
|
+
potential_ips = hop_details.split(/\s+/).select do |part|
|
141
|
+
part =~ /\b(?:\d{1,3}\.){3}\d{1,3}\b/
|
142
|
+
end
|
143
|
+
ips = potential_ips unless potential_ips.empty?
|
144
|
+
end
|
145
|
+
|
146
|
+
ips
|
147
|
+
end
|
148
|
+
|
149
|
+
def resolve_destination(destination)
|
150
|
+
# If destination is already an IPv4 address, return it
|
151
|
+
return destination if destination =~ /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
|
152
|
+
|
153
|
+
# Otherwise, resolve the hostname to an IPv4 address
|
154
|
+
begin
|
155
|
+
addresses = Resolv.getaddresses(destination)
|
156
|
+
|
157
|
+
# If no addresses are returned, the hostname is unresolvable
|
158
|
+
raise Resolv::ResolvError, "no address for #{destination}" if addresses.empty?
|
159
|
+
|
160
|
+
# Find the first IPv4 address
|
161
|
+
ipv4_address = addresses.find { |addr| addr =~ /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ }
|
162
|
+
|
163
|
+
# If no IPv4 address is found, raise an error
|
164
|
+
raise "No IPv4 address found for hostname: #{destination}" if ipv4_address.nil?
|
165
|
+
|
166
|
+
ipv4_address
|
167
|
+
rescue Resolv::ResolvError => e
|
168
|
+
raise "Unable to resolve hostname: #{e.message}"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def get_hostname(ip)
|
173
|
+
Timeout.timeout(1) { Resolv.getname(ip) }
|
174
|
+
rescue StandardError
|
175
|
+
nil
|
176
|
+
end
|
177
|
+
|
178
|
+
def trace_icmp(destination_ip)
|
179
|
+
# ICMP traceroute implementation
|
180
|
+
1.upto(@max_hops) do |ttl|
|
181
|
+
hop_info = trace_hop_icmp(destination_ip, ttl)
|
182
|
+
@results << hop_info
|
183
|
+
|
184
|
+
# Stop if we've reached the destination
|
185
|
+
break if hop_info[:ip] == destination_ip
|
186
|
+
# Stop if we've hit an unreachable marker
|
187
|
+
break if hop_info[:unreachable]
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def trace_hop_icmp(destination_ip, ttl)
|
192
|
+
hop_info = { ttl: ttl, responses: [] }
|
193
|
+
|
194
|
+
# Use ping with increasing TTL values
|
195
|
+
@queries.times do
|
196
|
+
Time.now
|
197
|
+
cmd = ping_command_with_ttl(destination_ip, ttl)
|
198
|
+
|
199
|
+
ip = nil
|
200
|
+
response_time = nil
|
201
|
+
|
202
|
+
# Execute the ping command and parse the output
|
203
|
+
begin
|
204
|
+
output = `#{cmd}`
|
205
|
+
|
206
|
+
# Parse the response to get the responding IP
|
207
|
+
if output =~ /from (\d+\.\d+\.\d+\.\d+).*time=(\d+\.?\d*)/
|
208
|
+
ip = ::Regexp.last_match(1)
|
209
|
+
response_time = ::Regexp.last_match(2).to_f
|
210
|
+
end
|
211
|
+
rescue StandardError
|
212
|
+
# Handle errors
|
213
|
+
end
|
214
|
+
|
215
|
+
hop_info[:responses] << {
|
216
|
+
ip: ip,
|
217
|
+
response_time: response_time,
|
218
|
+
timeout: ip.nil?
|
219
|
+
}
|
220
|
+
end
|
221
|
+
|
222
|
+
# Process the responses
|
223
|
+
process_hop_responses(hop_info)
|
224
|
+
end
|
225
|
+
|
226
|
+
def ping_command_with_ttl(ip, ttl)
|
227
|
+
case RbConfig::CONFIG["host_os"]
|
228
|
+
when /mswin|mingw|cygwin/
|
229
|
+
"ping -n 1 -i #{ttl} -w #{@timeout * 1000} #{ip}"
|
230
|
+
when /darwin/
|
231
|
+
"ping -c 1 -m #{ttl} -t #{@timeout} #{ip}"
|
232
|
+
else
|
233
|
+
"ping -c 1 -t #{ttl} -W #{@timeout} #{ip}"
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def trace_udp(destination_ip)
|
238
|
+
1.upto(@max_hops) do |ttl|
|
239
|
+
hop_info = trace_hop_udp(destination_ip, ttl)
|
240
|
+
@results << hop_info
|
241
|
+
|
242
|
+
# Stop if we've reached the destination or hit a destination unreachable
|
243
|
+
break if hop_info[:ip] == destination_ip || hop_info[:unreachable]
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def trace_hop_udp(destination_ip, ttl)
|
248
|
+
hop_info = { ttl: ttl, responses: [] }
|
249
|
+
|
250
|
+
# Create a listener socket for ICMP responses
|
251
|
+
icmp_socket = create_icmp_socket
|
252
|
+
|
253
|
+
@queries.times do |i|
|
254
|
+
start_time = Time.now
|
255
|
+
port = DEFAULT_PORT + i + (ttl * @queries)
|
256
|
+
|
257
|
+
begin
|
258
|
+
# Create and configure the sending socket
|
259
|
+
sender = UDPSocket.new
|
260
|
+
sender.setsockopt(Socket::IPPROTO_IP, Socket::IP_TTL, ttl)
|
261
|
+
|
262
|
+
# Send the UDP packet
|
263
|
+
Timeout.timeout(@timeout) do
|
264
|
+
sender.send("TRACE", 0, destination_ip, port)
|
265
|
+
|
266
|
+
# Wait for ICMP response
|
267
|
+
data, addr = icmp_socket.recvfrom(512)
|
268
|
+
response_time = ((Time.now - start_time) * 1000).round(2)
|
269
|
+
|
270
|
+
# The responding IP is in addr[2]
|
271
|
+
ip = addr[2]
|
272
|
+
|
273
|
+
# Check if we've received an ICMP destination unreachable message
|
274
|
+
unreachable = data.bytes[20] == 3 # ICMP Type 3 is Destination Unreachable
|
275
|
+
|
276
|
+
hop_info[:responses] << {
|
277
|
+
ip: ip,
|
278
|
+
response_time: response_time,
|
279
|
+
timeout: false,
|
280
|
+
unreachable: unreachable
|
281
|
+
}
|
282
|
+
end
|
283
|
+
rescue Timeout::Error
|
284
|
+
hop_info[:responses] << { ip: nil, response_time: nil, timeout: true }
|
285
|
+
rescue StandardError => e
|
286
|
+
hop_info[:responses] << { ip: nil, response_time: nil, timeout: true, error: e.message }
|
287
|
+
ensure
|
288
|
+
sender&.close
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
icmp_socket.close
|
293
|
+
process_hop_responses(hop_info)
|
294
|
+
end
|
295
|
+
|
296
|
+
def create_icmp_socket
|
297
|
+
socket = Socket.new(Socket::AF_INET, Socket::SOCK_RAW, Socket::IPPROTO_ICMP)
|
298
|
+
if RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/
|
299
|
+
# Windows requires different socket setup
|
300
|
+
else
|
301
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
302
|
+
end
|
303
|
+
socket
|
304
|
+
rescue Errno::EPERM, Errno::EACCES
|
305
|
+
raise "Must run as root/administrator to create raw sockets for traceroute"
|
306
|
+
end
|
307
|
+
|
308
|
+
def trace_tcp(destination_ip)
|
309
|
+
1.upto(@max_hops) do |ttl|
|
310
|
+
hop_info = trace_hop_tcp(destination_ip, ttl)
|
311
|
+
@results << hop_info
|
312
|
+
|
313
|
+
# Stop if we've reached the destination
|
314
|
+
break if hop_info[:ip] == destination_ip
|
315
|
+
# Stop if we've hit an unreachable marker
|
316
|
+
break if hop_info[:unreachable]
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
def trace_hop_tcp(destination_ip, ttl)
|
321
|
+
hop_info = { ttl: ttl, responses: [] }
|
322
|
+
|
323
|
+
@queries.times do |i|
|
324
|
+
# Use different ports for each query
|
325
|
+
port = 80 + i
|
326
|
+
start_time = Time.now
|
327
|
+
|
328
|
+
begin
|
329
|
+
# Create TCP socket with specific TTL
|
330
|
+
socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
|
331
|
+
socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_TTL, ttl)
|
332
|
+
|
333
|
+
# Attempt to connect with timeout
|
334
|
+
Timeout.timeout(@timeout) do
|
335
|
+
sockaddr = Socket.sockaddr_in(port, destination_ip)
|
336
|
+
socket.connect_nonblock(sockaddr)
|
337
|
+
end
|
338
|
+
|
339
|
+
# If we get here, we successfully connected (likely at the final hop)
|
340
|
+
response_time = ((Time.now - start_time) * 1000).round(2)
|
341
|
+
hop_info[:responses] << {
|
342
|
+
ip: destination_ip,
|
343
|
+
response_time: response_time,
|
344
|
+
timeout: false
|
345
|
+
}
|
346
|
+
rescue IO::WaitWritable
|
347
|
+
# Connection in progress - need to use select for non-blocking socket
|
348
|
+
response_time = nil
|
349
|
+
ip = nil
|
350
|
+
|
351
|
+
begin
|
352
|
+
Timeout.timeout(@timeout) do
|
353
|
+
_, writable, = IO.select(nil, [socket], nil, @timeout)
|
354
|
+
if writable&.any?
|
355
|
+
# Socket is writable, check for errors
|
356
|
+
begin
|
357
|
+
socket.connect_nonblock(sockaddr) # Will raise Errno::EISCONN if connected
|
358
|
+
rescue Errno::EISCONN
|
359
|
+
# Successfully connected
|
360
|
+
response_time = ((Time.now - start_time) * 1000).round(2)
|
361
|
+
ip = destination_ip
|
362
|
+
rescue SystemCallError
|
363
|
+
# Get the intermediary IP from the error
|
364
|
+
# This is a simplification - in reality, we'd need to use raw sockets
|
365
|
+
# and analyze TCP packets with specific TTL values
|
366
|
+
ip = nil
|
367
|
+
end
|
368
|
+
end
|
369
|
+
end
|
370
|
+
rescue Timeout::Error
|
371
|
+
hop_info[:responses] << { ip: nil, response_time: nil, timeout: true }
|
372
|
+
end
|
373
|
+
|
374
|
+
if ip
|
375
|
+
hop_info[:responses] << {
|
376
|
+
ip: ip,
|
377
|
+
response_time: response_time,
|
378
|
+
timeout: false
|
379
|
+
}
|
380
|
+
end
|
381
|
+
rescue SystemCallError, Timeout::Error
|
382
|
+
hop_info[:responses] << { ip: nil, response_time: nil, timeout: true }
|
383
|
+
ensure
|
384
|
+
socket&.close
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
process_hop_responses(hop_info)
|
389
|
+
end
|
390
|
+
|
391
|
+
def process_hop_responses(hop_info)
|
392
|
+
# Count timeouts
|
393
|
+
timeouts = hop_info[:responses].count { |r| r[:timeout] }
|
394
|
+
|
395
|
+
# If all queries timed out
|
396
|
+
return { ttl: hop_info[:ttl], ip: nil, hostname: nil, avg_time: nil, timeouts: timeouts } if timeouts == @queries
|
397
|
+
|
398
|
+
# Get all responding IPs (could be different if load balancing is in effect)
|
399
|
+
ips = hop_info[:responses].map { |r| r[:ip] }.compact.uniq
|
400
|
+
|
401
|
+
# Most common responding IP
|
402
|
+
ip = ips.max_by { |i| hop_info[:responses].count { |r| r[:ip] == i } }
|
403
|
+
|
404
|
+
# Calculate average response time for responses from the most common IP
|
405
|
+
valid_times = hop_info[:responses].select { |r| r[:ip] == ip && r[:response_time] }.map { |r| r[:response_time] }
|
406
|
+
avg_time = valid_times.empty? ? nil : (valid_times.sum / valid_times.size).round(2)
|
407
|
+
|
408
|
+
# Check if any responses indicated "unreachable"
|
409
|
+
unreachable = hop_info[:responses].any? { |r| r[:unreachable] }
|
410
|
+
|
411
|
+
# Get hostname for the IP
|
412
|
+
hostname = get_hostname(ip)
|
413
|
+
|
414
|
+
{
|
415
|
+
ttl: hop_info[:ttl],
|
416
|
+
ip: ip,
|
417
|
+
hostname: hostname,
|
418
|
+
avg_time: avg_time,
|
419
|
+
timeouts: timeouts,
|
420
|
+
unreachable: unreachable,
|
421
|
+
# Include all IPs if there are different ones (for load balancing detection)
|
422
|
+
all_ips: ips.size > 1 ? ips : nil
|
423
|
+
}
|
424
|
+
end
|
425
|
+
end
|
426
|
+
end
|
data/lib/lanet/version.rb
CHANGED
data/lib/lanet.rb
CHANGED
@@ -8,6 +8,8 @@ require "lanet/encryptor"
|
|
8
8
|
require "lanet/cli"
|
9
9
|
require "lanet/ping"
|
10
10
|
require "lanet/file_transfer"
|
11
|
+
require "lanet/mesh"
|
12
|
+
require "lanet/traceroute"
|
11
13
|
|
12
14
|
module Lanet
|
13
15
|
class Error < StandardError; end
|
@@ -50,5 +52,15 @@ module Lanet
|
|
50
52
|
def file_transfer(port = 5001)
|
51
53
|
FileTransfer.new(port)
|
52
54
|
end
|
55
|
+
|
56
|
+
# Create a new mesh network instance
|
57
|
+
def mesh_network(port = 5050, max_hops = 10)
|
58
|
+
Mesh.new(port, max_hops)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Create a new traceroute instance
|
62
|
+
def traceroute(protocol: :udp, max_hops: 30, timeout: 1, queries: 3)
|
63
|
+
Traceroute.new(protocol: protocol, max_hops: max_hops, timeout: timeout, queries: queries)
|
64
|
+
end
|
53
65
|
end
|
54
66
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lanet
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Davide Santangelo
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-03-
|
11
|
+
date: 2025-03-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: thor
|
@@ -94,11 +94,13 @@ files:
|
|
94
94
|
- lib/lanet/cli.rb
|
95
95
|
- lib/lanet/encryptor.rb
|
96
96
|
- lib/lanet/file_transfer.rb
|
97
|
+
- lib/lanet/mesh.rb
|
97
98
|
- lib/lanet/ping.rb
|
98
99
|
- lib/lanet/receiver.rb
|
99
100
|
- lib/lanet/scanner.rb
|
100
101
|
- lib/lanet/sender.rb
|
101
102
|
- lib/lanet/signer.rb
|
103
|
+
- lib/lanet/traceroute.rb
|
102
104
|
- lib/lanet/version.rb
|
103
105
|
- sig/lanet.rbs
|
104
106
|
homepage: https://github.com/davidesantangelo/lanet
|