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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lanet
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.3.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-08 00:00:00.000000000 Z
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