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.
data/lib/lanet/cli.rb CHANGED
@@ -6,6 +6,7 @@ require "lanet/receiver"
6
6
  require "lanet/scanner"
7
7
  require "lanet/ping"
8
8
  require "lanet/encryptor"
9
+ require "lanet/traceroute"
9
10
 
10
11
  module Lanet
11
12
  class CLI < Thor
@@ -278,6 +279,150 @@ module Lanet
278
279
  puts "Lanet version #{Lanet::VERSION}"
279
280
  end
280
281
 
282
+ desc "mesh start", "Start a mesh network node"
283
+ option :port, type: :numeric, default: 5050, desc: "Port for mesh communication"
284
+ option :max_hops, type: :numeric, default: 10, desc: "Maximum number of hops for message routing"
285
+ def mesh_start
286
+ mesh = Lanet::Mesh.new(options[:port], options[:max_hops])
287
+
288
+ puts "Starting mesh network node with ID: #{mesh.node_id}"
289
+ puts "Listening on port: #{options[:port]}"
290
+ puts "Press Ctrl+C to stop"
291
+
292
+ mesh.start
293
+
294
+ # Keep the process running
295
+ begin
296
+ loop do
297
+ sleep 1
298
+ end
299
+ rescue Interrupt
300
+ puts "\nStopping mesh network node..."
301
+ mesh.stop
302
+ end
303
+ end
304
+
305
+ desc "mesh send", "Send a message through the mesh network"
306
+ option :target, type: :string, required: true, desc: "Target node ID"
307
+ option :message, type: :string, required: true, desc: "Message to send"
308
+ option :key, type: :string, desc: "Encryption key (optional)"
309
+ option :private_key_file, type: :string, desc: "Path to private key file for signing (optional)"
310
+ option :port, type: :numeric, default: 5050, desc: "Port for mesh communication"
311
+ def mesh_send
312
+ mesh = Lanet::Mesh.new(options[:port])
313
+
314
+ private_key = nil
315
+ if options[:private_key_file]
316
+ begin
317
+ private_key = File.read(options[:private_key_file])
318
+ puts "Message will be digitally signed"
319
+ rescue StandardError => e
320
+ puts "Error reading private key file: #{e.message}"
321
+ return
322
+ end
323
+ end
324
+
325
+ # Initialize the mesh network
326
+ mesh.start
327
+
328
+ begin
329
+ message_id = mesh.send_message(
330
+ options[:target],
331
+ options[:message],
332
+ options[:key],
333
+ private_key
334
+ )
335
+
336
+ puts "Message sent through mesh network"
337
+ puts "Message ID: #{message_id}"
338
+ rescue Lanet::Mesh::Error => e
339
+ puts "Error sending mesh message: #{e.message}"
340
+ ensure
341
+ mesh.stop
342
+ end
343
+ end
344
+
345
+ desc "mesh info", "Display information about the mesh network"
346
+ option :port, type: :numeric, default: 5050, desc: "Port for mesh communication"
347
+ def mesh_info
348
+ mesh = Lanet::Mesh.new(options[:port])
349
+
350
+ # Load state if available
351
+ mesh.start
352
+
353
+ puts "Mesh Node ID: #{mesh.node_id}"
354
+ puts "\nConnected nodes:"
355
+
356
+ if mesh.connections.empty?
357
+ puts " No direct connections"
358
+ else
359
+ mesh.connections.each do |node_id, info|
360
+ last_seen = Time.now.to_i - info[:last_seen]
361
+ puts " #{node_id} (#{info[:ip]}, last seen #{last_seen}s ago)"
362
+ end
363
+ end
364
+
365
+ puts "\nMessage cache: #{mesh.message_cache.size} messages"
366
+
367
+ mesh.stop
368
+ end
369
+
370
+ desc "traceroute [HOST]", "Trace the route to a target host using different protocols"
371
+ method_option :host, type: :string, desc: "Target host to trace route"
372
+ method_option :protocol, type: :string, default: "udp", desc: "Protocol to use (icmp, udp, tcp)"
373
+ method_option :max_hops, type: :numeric, default: 30, desc: "Maximum number of hops"
374
+ method_option :timeout, type: :numeric, default: 1, desc: "Timeout in seconds for each probe"
375
+ method_option :queries, type: :numeric, default: 3, desc: "Number of queries per hop"
376
+ def traceroute(single_host = nil)
377
+ # Use the positional parameter if provided, otherwise use the --host option
378
+ target_host = single_host || options[:host]
379
+
380
+ # Ensure we have a host to trace
381
+ unless target_host
382
+ puts "Error: No host specified. Please provide a host as an argument or use --host option."
383
+ return
384
+ end
385
+
386
+ tracer = Lanet::Traceroute.new(
387
+ protocol: options[:protocol].to_sym,
388
+ max_hops: options[:max_hops],
389
+ timeout: options[:timeout],
390
+ queries: options[:queries]
391
+ )
392
+
393
+ puts "Tracing route to #{target_host} using #{options[:protocol].upcase} protocol"
394
+ puts "Maximum hops: #{options[:max_hops]}, Timeout: #{options[:timeout]}s, Queries: #{options[:queries]}"
395
+ puts "=" * 70
396
+ puts format("%3s %-15s %-30s %-10s", "TTL", "IP Address", "Hostname", "Response Time")
397
+ puts "-" * 70
398
+
399
+ tracer.trace(target_host).each do |hop|
400
+ if hop[:ip].nil?
401
+ puts format("%3d %-15s %-30s %-10s", hop[:ttl], "*", "*", "Request timed out")
402
+ else
403
+ hostname = hop[:hostname] || ""
404
+ time_str = hop[:avg_time] ? "#{hop[:avg_time]}ms" : "*"
405
+ puts format("%3d %-15s %-30s %-10s", hop[:ttl], hop[:ip], hostname, time_str)
406
+
407
+ # Show all IPs if there are multiple (for load balancing detection)
408
+ if hop[:all_ips] && hop[:all_ips].size > 1
409
+ puts " Multiple IPs detected (possible load balancing):"
410
+ hop[:all_ips].each do |ip|
411
+ puts " - #{ip}"
412
+ end
413
+ end
414
+
415
+ # Show unreachable marker
416
+ puts " Destination unreachable" if hop[:unreachable]
417
+ end
418
+ end
419
+ puts "=" * 70
420
+ puts "Trace complete."
421
+ rescue StandardError => e
422
+ puts "Error performing traceroute: #{e.message}"
423
+ puts e.backtrace if options[:verbose]
424
+ end
425
+
281
426
  private
282
427
 
283
428
  def display_ping_details(host, result)
@@ -122,7 +122,10 @@ module Lanet
122
122
  end
123
123
 
124
124
  ### Receive File Method
125
- def receive_file(output_dir, encryption_key = nil, public_key = nil, progress_callback = nil)
125
+ def receive_file(output_dir, encryption_key = nil, public_key = nil, progress_callback = nil, &block)
126
+ # Use the block parameter if provided and progress_callback is nil
127
+ progress_callback = block if block_given? && progress_callback.nil?
128
+
126
129
  FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
127
130
  receiver = UDPSocket.new
128
131
  receiver.bind("0.0.0.0", @port)
@@ -131,6 +134,10 @@ module Lanet
131
134
  begin
132
135
  loop do
133
136
  data, addr = receiver.recvfrom(65_536) # Large buffer for chunks
137
+
138
+ # Skip if we received nil data or address
139
+ next if addr.nil? || data.nil?
140
+
134
141
  sender_ip = addr[3]
135
142
  result = Lanet::Encryptor.process_message(data, encryption_key, public_key)
136
143
  next unless result[:content]&.length&.> 2
data/lib/lanet/mesh.rb ADDED
@@ -0,0 +1,493 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "set"
5
+ require "json"
6
+ require "fileutils"
7
+ require "logger"
8
+
9
+ module Lanet
10
+ class Mesh
11
+ class Error < StandardError; end
12
+
13
+ DEFAULT_TTL = 10
14
+ DEFAULT_MESH_PORT = 5050
15
+ DEFAULT_DISCOVERY_INTERVAL = 60 # seconds
16
+ DEFAULT_MESSAGE_EXPIRY = 600 # 10 minutes
17
+ DEFAULT_CONNECTION_TIMEOUT = 180 # 3x discovery interval
18
+
19
+ MESSAGE_TYPES = {
20
+ discovery: "DISCOVERY",
21
+ discovery_response: "DISCOVERY_RESPONSE",
22
+ message: "MESSAGE",
23
+ route: "ROUTE_INFO"
24
+ }.freeze
25
+
26
+ attr_reader :node_id, :connections, :message_cache, :logger
27
+
28
+ def initialize(port = DEFAULT_MESH_PORT, max_hops = DEFAULT_TTL,
29
+ discovery_interval = DEFAULT_DISCOVERY_INTERVAL,
30
+ message_expiry = DEFAULT_MESSAGE_EXPIRY,
31
+ logger = nil)
32
+ @port = port
33
+ @max_hops = max_hops
34
+ @discovery_interval = discovery_interval
35
+ @message_expiry = message_expiry
36
+ @connection_timeout = @discovery_interval * 3
37
+ @node_id = SecureRandom.uuid
38
+ @connections = {}
39
+ @routes = {}
40
+ @message_cache = Set.new
41
+ @message_timestamps = {}
42
+ @processed_message_count = 0
43
+ @mutex = Mutex.new
44
+ @logger = logger || Logger.new($stdout)
45
+ @logger.level = Logger::INFO
46
+
47
+ # Setup communication channels
48
+ @sender = Lanet::Sender.new(port)
49
+ @receiver = nil
50
+
51
+ # For handling data storage
52
+ @storage_path = File.join(Dir.home, ".lanet", "mesh")
53
+ FileUtils.mkdir_p(@storage_path) unless Dir.exist?(@storage_path)
54
+ end
55
+
56
+ def start
57
+ return if @running
58
+
59
+ @running = true
60
+ start_receiver
61
+ start_discovery_service
62
+ start_monitoring
63
+ start_cache_pruning
64
+
65
+ @logger.info("Mesh node #{@node_id} started on port #{@port}")
66
+ load_state
67
+ end
68
+
69
+ def stop
70
+ return unless @running
71
+
72
+ @logger.info("Stopping mesh node #{@node_id}")
73
+ @running = false
74
+
75
+ [@discovery_thread, @receiver_thread, @monitor_thread, @cache_pruning_thread].each do |thread|
76
+ thread&.exit
77
+ end
78
+
79
+ save_state
80
+ @logger.info("Mesh node stopped")
81
+ end
82
+
83
+ def send_message(target_id, message, encryption_key = nil, private_key = nil)
84
+ unless @connections.key?(target_id) || @routes.key?(target_id)
85
+ @logger.debug("No known route to #{target_id}, performing discovery")
86
+ perform_discovery
87
+
88
+ # Replace sleep with timeout-based approach
89
+ discovery_timeout = Time.now.to_i + 2
90
+ until @connections.key?(target_id) || @routes.key?(target_id) || Time.now.to_i > discovery_timeout
91
+ sleep 0.1 # Short sleep to avoid CPU spinning
92
+ end
93
+
94
+ unless @connections.key?(target_id) || @routes.key?(target_id)
95
+ @logger.error("No route to node #{target_id}")
96
+ raise Error, "No route to node #{target_id}"
97
+ end
98
+ end
99
+
100
+ message_id = SecureRandom.uuid
101
+
102
+ # Prevent message loops by adding to cache
103
+ @mutex.synchronize do
104
+ @message_cache.add(message_id)
105
+ @message_timestamps[message_id] = Time.now.to_i
106
+ end
107
+
108
+ # Prepare the mesh message container
109
+ encrypted_content = encryption_key ? Encryptor.prepare_message(message, encryption_key, private_key) : message
110
+ mesh_message = build_mesh_message(
111
+ MESSAGE_TYPES[:message],
112
+ id: message_id,
113
+ target: target_id,
114
+ content: encrypted_content,
115
+ hops: 0
116
+ )
117
+
118
+ # Direct connection
119
+ if @connections.key?(target_id)
120
+ @logger.debug("Sending direct message to #{target_id}")
121
+ @sender.send_to(@connections[target_id][:ip], mesh_message.to_json)
122
+ return message_id
123
+ end
124
+
125
+ # Route through intermediate node
126
+ if @routes.key?(target_id)
127
+ next_hop = @routes[target_id][:next_hop]
128
+ if @connections.key?(next_hop)
129
+ @logger.debug("Sending message to #{target_id} via #{next_hop}")
130
+ @sender.send_to(@connections[next_hop][:ip], mesh_message.to_json)
131
+ return message_id
132
+ end
133
+ end
134
+
135
+ # Broadcast as last resort
136
+ @logger.debug("Broadcasting message to find route to #{target_id}")
137
+ broadcast_mesh_message(mesh_message)
138
+ message_id
139
+ end
140
+
141
+ def broadcast_mesh_message(mesh_message)
142
+ @connections.each do |id, info|
143
+ next if id == mesh_message[:origin]
144
+
145
+ @sender.send_to(info[:ip], mesh_message.to_json)
146
+ end
147
+ end
148
+
149
+ def healthy?
150
+ @running &&
151
+ @receiver_thread&.alive? &&
152
+ @discovery_thread&.alive? &&
153
+ @monitor_thread&.alive? &&
154
+ @cache_pruning_thread&.alive?
155
+ end
156
+
157
+ def stats
158
+ {
159
+ node_id: @node_id,
160
+ connections: @connections.size,
161
+ routes: @routes.size,
162
+ message_cache_size: @message_cache.size,
163
+ processed_messages: @processed_message_count
164
+ }
165
+ end
166
+
167
+ private
168
+
169
+ def build_mesh_message(type, extra_fields = {})
170
+ {
171
+ type: type,
172
+ id: extra_fields[:id] || SecureRandom.uuid,
173
+ origin: @node_id,
174
+ timestamp: Time.now.to_i
175
+ }.merge(extra_fields)
176
+ end
177
+
178
+ def start_receiver
179
+ @receiver = Lanet::Receiver.new(@port)
180
+ @receiver_thread = Thread.new do
181
+ @logger.info("Starting receiver on port #{@port}")
182
+ @receiver.listen do |data, sender_ip|
183
+ handle_incoming_data(data, sender_ip)
184
+ rescue StandardError => e
185
+ @logger.error("Error handling mesh message: #{e.message}")
186
+ @logger.error(e.backtrace.join("\n")) if @logger.debug?
187
+ end
188
+ end
189
+ end
190
+
191
+ def start_monitoring
192
+ @monitor_thread = Thread.new do
193
+ @logger.info("Starting thread monitor")
194
+
195
+ while @running
196
+ unless @receiver_thread&.alive?
197
+ @logger.warn("Receiver thread died, restarting...")
198
+ start_receiver
199
+ end
200
+
201
+ unless @discovery_thread&.alive?
202
+ @logger.warn("Discovery thread died, restarting...")
203
+ start_discovery_service
204
+ end
205
+
206
+ unless @cache_pruning_thread&.alive?
207
+ @logger.warn("Cache pruning thread died, restarting...")
208
+ start_cache_pruning
209
+ end
210
+
211
+ sleep 30
212
+ end
213
+ end
214
+ end
215
+
216
+ def start_cache_pruning
217
+ @cache_pruning_thread = Thread.new do
218
+ @logger.info("Starting cache pruning service")
219
+
220
+ while @running
221
+ prune_message_cache
222
+ sleep @discovery_interval
223
+ end
224
+ end
225
+ end
226
+
227
+ def handle_incoming_data(data, sender_ip)
228
+ message = JSON.parse(data, symbolize_names: true)
229
+
230
+ # Track metrics
231
+ @mutex.synchronize { @processed_message_count += 1 }
232
+
233
+ # Discard messages older than configured expiry time
234
+ if message[:timestamp] < Time.now.to_i - @message_expiry
235
+ @logger.debug("Discarding expired message: #{message[:id]}")
236
+ return
237
+ end
238
+
239
+ # Skip messages we've already processed
240
+ if @message_cache.include?(message[:id])
241
+ @logger.debug("Skipping already processed message: #{message[:id]}")
242
+ return
243
+ end
244
+
245
+ # Add to cache to prevent loops
246
+ @mutex.synchronize do
247
+ @message_cache.add(message[:id])
248
+ @message_timestamps[message[:id]] = Time.now.to_i
249
+ end
250
+
251
+ # Dispatch to appropriate handler
252
+ case message[:type]
253
+ when MESSAGE_TYPES[:discovery]
254
+ handle_discovery(message, sender_ip)
255
+ when MESSAGE_TYPES[:discovery_response]
256
+ handle_discovery_response(message, sender_ip)
257
+ when MESSAGE_TYPES[:message]
258
+ handle_message(message, sender_ip)
259
+ when MESSAGE_TYPES[:route]
260
+ handle_route_info(message, sender_ip)
261
+ else
262
+ @logger.warn("Unknown message type: #{message[:type]}")
263
+ end
264
+ rescue JSON::ParserError => e
265
+ @logger.debug("Ignoring non-JSON message: #{e.message[0..100]}")
266
+ rescue StandardError => e
267
+ @logger.error("Error processing message: #{e.message}")
268
+ @logger.error(e.backtrace.join("\n")) if @logger.debug?
269
+ end
270
+
271
+ def handle_discovery(message, sender_ip)
272
+ # Add the sender to our connections
273
+ @mutex.synchronize do
274
+ @connections[message[:origin]] = {
275
+ ip: sender_ip,
276
+ last_seen: Time.now.to_i
277
+ }
278
+ end
279
+
280
+ @logger.debug("Added connection to #{message[:origin]} at #{sender_ip}")
281
+
282
+ # Send a discovery response
283
+ response = build_mesh_message(
284
+ MESSAGE_TYPES[:discovery_response],
285
+ target: message[:origin],
286
+ known_nodes: @connections.keys
287
+ )
288
+
289
+ @sender.send_to(sender_ip, response.to_json)
290
+
291
+ # Share route information
292
+ share_routes_with(message[:origin])
293
+ end
294
+
295
+ def handle_discovery_response(message, sender_ip)
296
+ @mutex.synchronize do
297
+ # Update our connection to the sender
298
+ @connections[message[:origin]] = {
299
+ ip: sender_ip,
300
+ last_seen: Time.now.to_i
301
+ }
302
+
303
+ # Add routes for known nodes with correct distance
304
+ message[:known_nodes].each do |node_id|
305
+ next if node_id == @node_id || @connections.key?(node_id)
306
+
307
+ @routes[node_id] = {
308
+ next_hop: message[:origin],
309
+ distance: 2, # Corrected to reflect hops through responder
310
+ last_updated: Time.now.to_i
311
+ }
312
+
313
+ @logger.debug("Added route to #{node_id} via #{message[:origin]} (distance: 2)")
314
+ end
315
+ end
316
+ end
317
+
318
+ def handle_message(message, _sender_ip)
319
+ # If message is for us, process it
320
+ if message[:target] == @node_id
321
+ @logger.info("Received mesh message from #{message[:origin]}: #{message[:content]}")
322
+ return
323
+ end
324
+
325
+ # Otherwise, forward if we haven't exceeded max hops
326
+ if message[:hops] >= @max_hops
327
+ @logger.debug("Message exceeded max hops (#{@max_hops}), dropping")
328
+ return
329
+ end
330
+
331
+ message[:hops] += 1
332
+ @logger.debug("Forwarding message from #{message[:origin]} to #{message[:target]} (hop #{message[:hops]})")
333
+
334
+ if @connections.key?(message[:target])
335
+ @sender.send_to(@connections[message[:target]][:ip], message.to_json)
336
+ elsif @routes.key?(message[:target])
337
+ next_hop = @routes[message[:target]][:next_hop]
338
+ if @connections.key?(next_hop)
339
+ @sender.send_to(@connections[next_hop][:ip], message.to_json)
340
+ else
341
+ @logger.warn("Lost connection to next hop #{next_hop}, broadcasting")
342
+ broadcast_mesh_message(message)
343
+ end
344
+ else
345
+ broadcast_mesh_message(message)
346
+ end
347
+ end
348
+
349
+ def handle_route_info(message, _sender_ip)
350
+ @mutex.synchronize do
351
+ message[:routes].each do |node_id, route_info|
352
+ next if node_id.to_s == @node_id || @connections.key?(node_id.to_s)
353
+
354
+ distance = route_info[:distance] + 1
355
+
356
+ # Only update if we don't have a route or the new route is better
357
+ next unless !@routes.key?(node_id.to_s) || @routes[node_id.to_s][:distance] > distance
358
+
359
+ @routes[node_id.to_s] = {
360
+ next_hop: message[:origin],
361
+ distance: distance,
362
+ last_updated: Time.now.to_i
363
+ }
364
+
365
+ @logger.debug("Updated route to #{node_id} via #{message[:origin]} (distance: #{distance})")
366
+ end
367
+ end
368
+ end
369
+
370
+ def start_discovery_service
371
+ @discovery_thread = Thread.new do
372
+ @logger.info("Starting discovery service")
373
+
374
+ while @running
375
+ perform_discovery
376
+ prune_old_connections
377
+ sleep @discovery_interval
378
+ end
379
+ end
380
+ end
381
+
382
+ def perform_discovery
383
+ @logger.debug("Performing network discovery")
384
+ discovery_message = build_mesh_message(MESSAGE_TYPES[:discovery])
385
+ @sender.broadcast(discovery_message.to_json)
386
+ end
387
+
388
+ def share_routes_with(target_node_id)
389
+ return unless @connections.key?(target_node_id)
390
+
391
+ @logger.debug("Sharing route information with #{target_node_id}")
392
+ route_message = build_mesh_message(MESSAGE_TYPES[:route], routes: @routes)
393
+ @sender.send_to(@connections[target_node_id][:ip], route_message.to_json)
394
+ end
395
+
396
+ def prune_old_connections
397
+ now = Time.now.to_i
398
+ pruned_connections = 0
399
+ pruned_routes = 0
400
+
401
+ @mutex.synchronize do
402
+ # Remove old connections
403
+ @connections.each do |id, info|
404
+ next unless now - info[:last_seen] > @connection_timeout
405
+
406
+ @connections.delete(id)
407
+ pruned_connections += 1
408
+ @logger.debug("Pruned stale connection to #{id}")
409
+ end
410
+
411
+ # Remove old routes
412
+ @routes.each do |id, info|
413
+ next unless now - info[:last_updated] > @connection_timeout
414
+
415
+ @routes.delete(id)
416
+ pruned_routes += 1
417
+ @logger.debug("Pruned stale route to #{id}")
418
+ end
419
+ end
420
+
421
+ return unless pruned_connections.positive? || pruned_routes.positive?
422
+
423
+ @logger.info("Pruned #{pruned_connections} connections and #{pruned_routes} routes")
424
+ end
425
+
426
+ def prune_message_cache
427
+ now = Time.now.to_i
428
+ pruned_count = 0
429
+
430
+ @mutex.synchronize do
431
+ @message_timestamps.each do |msg_id, timestamp|
432
+ next unless now - timestamp > @message_expiry
433
+
434
+ @message_cache.delete(msg_id)
435
+ @message_timestamps.delete(msg_id)
436
+ pruned_count += 1
437
+ end
438
+ end
439
+
440
+ @logger.info("Pruned #{pruned_count} messages from cache") if pruned_count.positive?
441
+ end
442
+
443
+ def save_state
444
+ state = {
445
+ node_id: @node_id,
446
+ connections: @connections,
447
+ routes: @routes,
448
+ timestamp: Time.now.to_i
449
+ }
450
+
451
+ begin
452
+ File.write(File.join(@storage_path, "state.json"), state.to_json)
453
+ @logger.info("Mesh state saved successfully")
454
+ rescue StandardError => e
455
+ @logger.error("Failed to save mesh state: #{e.message}")
456
+ end
457
+ end
458
+
459
+ def load_state
460
+ state_file = File.join(@storage_path, "state.json")
461
+ return unless File.exist?(state_file)
462
+
463
+ begin
464
+ @logger.info("Loading mesh state from #{state_file}")
465
+ state = JSON.parse(File.read(state_file), symbolize_names: true)
466
+ validate_state(state)
467
+
468
+ @node_id = state[:node_id]
469
+ @connections = state[:connections]
470
+ @routes = state[:routes]
471
+
472
+ @logger.info("Mesh state loaded successfully, node ID: #{@node_id}")
473
+ rescue JSON::ParserError => e
474
+ @logger.error("Error parsing mesh state file: #{e.message}")
475
+ rescue KeyError => e
476
+ @logger.error("Invalid mesh state structure: #{e.message}")
477
+ rescue StandardError => e
478
+ @logger.error("Error loading mesh state: #{e.message}")
479
+ end
480
+ end
481
+
482
+ def validate_state(state)
483
+ %i[node_id connections routes timestamp].each do |key|
484
+ raise KeyError, "Missing required key: #{key}" unless state.key?(key)
485
+ end
486
+
487
+ # Verify timestamp is reasonable
488
+ return unless state[:timestamp] < Time.now.to_i - 30 * 24 * 60 * 60
489
+
490
+ @logger.warn("State file is more than 30 days old")
491
+ end
492
+ end
493
+ end