free-range 0.1.3 → 0.2.1

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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/free-range.rb +171 -76
  3. data/lib/free-range.rb.~ +641 -0
  4. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c353a0a3ea2e3a261a5cb6af72b3e5e4ac847be178a710e79927264e256393a
4
- data.tar.gz: 771ead5640626c25b48b5639746246a08a44bb2f58db8cca3971581257de97e8
3
+ metadata.gz: ce3d41efcb7e6ee1906179227559f6d004b2d07816b3aa230756f4b64f6f0327
4
+ data.tar.gz: f61a1feddd95066226602b0540c54efeada8a2846c6eb8ba297655a76240454d
5
5
  SHA512:
6
- metadata.gz: ef260cde29dfee53e9ab895c14a232c0e023052cb2c614e2ce3e7d34ffbdefebabbe856c2fbaa143179b7fa387468dd32d89718da69ab22d669230fd2a164a76
7
- data.tar.gz: 2477e3ea176e80ceb40869bf1e2835ffd63b4a8e733fb87d3f4497bcc70d27d74814f94586e946b2c5b24f8db9f7359f4bb60c7a3087078927d608d03234915d
6
+ metadata.gz: 2bb942063fff21ce9e3a80e1b6223986da7c2a6ff632d01eb05c2e9c2c1a00faaf6655cc544d5b74fcfe14e5dd64c933d581a016e78078c53d8308f98a1af488
7
+ data.tar.gz: ce98a667f77b4ee2cf0dccca0cba7655b18ae2251e4e5284051bb366f725f58fc55c47ccaeb01522e056e2d68658804ffb6aee6d5a19d28d4bd23d235c8e56c3
data/lib/free-range.rb CHANGED
@@ -12,6 +12,60 @@ module FreeRange
12
12
  exit 1
13
13
  end
14
14
 
15
+ # Клас для зберігання конфігураційних команд
16
+ class Config
17
+ attr_accessor :username, :password
18
+
19
+ # Ініціалізує об’єкт конфігурації
20
+ # @param login [Hash] Hash with target, username, and password
21
+ # @yield [self] Yields self for block-based configuration
22
+ def initialize(login)
23
+ @login = login
24
+ @username = nil
25
+ @password = nil
26
+ yield self if block_given?
27
+ end
28
+
29
+ # Повертає команду SSH
30
+ # @return [String] SSH command string
31
+ def ssh_command
32
+ "sshpass -p \"#{@login[:password]}\" ssh -C -x -4 -o StrictHostKeyChecking=no #{@login[:username]}@#{@login[:target]}"
33
+ end
34
+
35
+ # Повертає команду для отримання даних про передплатників
36
+ # @return [String] Subscribers command string
37
+ def subscribers_command
38
+ "ssh -C -x roffice /usr/local/share/noc/bin/radius-subscribers"
39
+ end
40
+
41
+ # Повертає команду для отримання списку інтерфейсів
42
+ # @return [String] Command to fetch interfaces
43
+ def command_interfaces
44
+ 'show configuration interfaces | no-more | display set | match dynamic-profile | match "ranges ([0-9]+(-[0-9]+)?)"'
45
+ end
46
+
47
+ # Повертає команду для отримання діапазонів VLAN
48
+ # @param interface [String, nil] Interface name or nil for all interfaces
49
+ # @return [String] Command to fetch VLAN ranges
50
+ def command_ranges(interface = nil)
51
+ interface ? "show configuration interfaces #{interface} | no-more | display set | match dynamic-profile | match \"ranges ([0-9]+(-[0-9]+)?)\"" : 'show configuration interfaces | no-more | display set | match dynamic-profile | match "ranges ([0-9]+(-[0-9]+)?)"'
52
+ end
53
+
54
+ # Повертає команду для отримання демультиплексорних VLAN
55
+ # @param interface [String, nil] Interface name or nil for all interfaces
56
+ # @return [String] Command to fetch demux VLANs
57
+ def command_demux(interface = nil)
58
+ interface ? "show configuration interfaces #{interface} | display set | match unnumbered-address" : 'show configuration interfaces | display set | match unnumbered-address'
59
+ end
60
+
61
+ # Повертає команду для отримання інших VLAN
62
+ # @param interface [String, nil] Interface name or nil for all interfaces
63
+ # @return [String] Command to fetch other VLANs
64
+ def command_another(interface = nil)
65
+ interface ? "show configuration interfaces #{interface} | display set | match vlan-id" : 'show configuration interfaces | display set | match vlan-id'
66
+ end
67
+ end
68
+
15
69
  # Абстрактний клас для роботи з VLAN
16
70
  class VlanContainer
17
71
  def initialize
@@ -411,16 +465,14 @@ module FreeRange
411
465
  end
412
466
 
413
467
  # Метод для заповнення Ranges для одного інтерфейсу
414
- # @param ssh_command [String] SSH command to execute
468
+ # @param config [Config] Configuration object with commands
415
469
  # @param interface [String, nil] Interface name or nil for all interfaces
416
470
  # @param ranges [Ranges] Object to store VLAN ranges
471
+ # @param debug [Boolean] Enable debug output
417
472
  # @return [void]
418
- def self.process_interface(ssh_command, interface, ranges)
419
- command_ranges = interface ? "show configuration interfaces #{interface} | no-more | display set | match dynamic-profile | match \"ranges ([0-9]+(-[0-9]+)?)\"" : 'show configuration interfaces | no-more | display set | match dynamic-profile | match "ranges ([0-9]+(-[0-9]+)?)"'
420
- command_demux = interface ? "show configuration interfaces #{interface} | display set | match unnumbered-address" : 'show configuration interfaces | display set | match unnumbered-address'
421
- command_another = interface ? "show configuration interfaces #{interface} | display set | match vlan-id" : 'show configuration interfaces | display set | match vlan-id'
422
-
423
- full_cmd = "#{ssh_command} '#{command_ranges}'"
473
+ def self.process_interface(config, interface, ranges, debug = false)
474
+ full_cmd = "#{config.ssh_command} '#{config.command_ranges(interface)}'"
475
+ puts "[DEBUG] Executing command: #{full_cmd}" if debug
424
476
  result = `#{full_cmd}`.strip
425
477
  unless result.empty?
426
478
  result.each_line do |line|
@@ -432,7 +484,8 @@ module FreeRange
432
484
  end
433
485
  end
434
486
 
435
- full_cmd = "#{ssh_command} '#{command_demux}'"
487
+ full_cmd = "#{config.ssh_command} '#{config.command_demux(interface)}'"
488
+ puts "[DEBUG] Executing command: #{full_cmd}" if debug
436
489
  result = `#{full_cmd}`.strip
437
490
  unless result.empty?
438
491
  result.each_line do |line|
@@ -446,7 +499,8 @@ module FreeRange
446
499
  end
447
500
  end
448
501
 
449
- full_cmd = "#{ssh_command} '#{command_another}'"
502
+ full_cmd = "#{config.ssh_command} '#{config.command_another(interface)}'"
503
+ puts "[DEBUG] Executing command: #{full_cmd}" if debug
450
504
  result = `#{full_cmd}`.strip
451
505
  unless result.empty?
452
506
  result.each_line do |line|
@@ -461,12 +515,58 @@ module FreeRange
461
515
  end
462
516
  end
463
517
 
518
+ # Обробляє VLAN для інтерфейсу та виводить результати
519
+ # @param config [Config] Configuration object with commands
520
+ # @param interface [String, nil] Interface name or nil for all interfaces
521
+ # @param subscribers_result [String] Result of subscribers command
522
+ # @param target [String] Target device hostname
523
+ # @param use_color [Boolean] Enable colored output
524
+ # @param debug [Boolean] Enable debug output
525
+ # @param table_mode [Boolean] Display VLAN distribution table
526
+ # @param table_png_mode [String, nil] Path to save PNG or nil
527
+ # @return [void]
528
+ def self.process_and_output(config, interface, subscribers_result, target, use_color, debug, table_mode, table_png_mode)
529
+ ranges = Ranges.new
530
+ vlans = Vlans.new
531
+ subscribers_result.each_line do |line|
532
+ if line.split.first =~ /dhcp(?:_[0-9a-fA-F.]+)?_([^:]+):(\d+)@#{Regexp.escape(target)}$/
533
+ subscriber_interface, vlan = $1, $2.to_i
534
+ if interface
535
+ vlans.add_vlan(vlan) if subscriber_interface == interface && vlan > 0
536
+ else
537
+ vlans.add_vlan(vlan) if vlan > 0
538
+ end
539
+ end
540
+ end
541
+
542
+ process_interface(config, interface, ranges, debug)
543
+ if debug
544
+ puts "\nІнтерфейс: #{interface}" if interface
545
+ Print.ranged(ranges)
546
+ Print.vlans(vlans)
547
+ Print.vlan_ranges(vlans)
548
+ puts
549
+ end
550
+ if table_png_mode
551
+ Print.table_png(ranges, vlans, table_png_mode, target, interface)
552
+ elsif table_mode
553
+ Print.table(ranges, vlans, use_color, target, interface)
554
+ else
555
+ Print.combined_ranges(ranges, vlans, use_color, target, interface)
556
+ end
557
+ end
558
+
464
559
  # Основна логіка виконання
465
560
  # @return [void]
466
561
  def self.run
467
562
  options = {}
468
563
  OptionParser.new do |opts|
469
- opts.banner = "Використання: free-range <IP-адреса або hostname> [опції]"
564
+ opts.banner = <<~BANNER
565
+ Використання: free-range <IP-адреса або hostname> [опції]
566
+
567
+ Аналізує розподіл VLAN на мережевих пристроях, генеруючи таблиці або PNG-зображення.
568
+ BANNER
569
+ opts.on("-h", "--help", "Показати цю довідку") { puts opts; exit 0 }
470
570
  opts.on("-u", "--username USERNAME", "Ім'я користувача для SSH") { |u| options[:username] = u }
471
571
  opts.on("-p", "--password PASSWORD", "Пароль для SSH") { |p| options[:password] = p }
472
572
  opts.on("-n", "--no-color", "Вимкнути кольоровий вивід") { options[:no_color] = true }
@@ -474,44 +574,89 @@ module FreeRange
474
574
  opts.on("-t", "--table", "Вивести діаграму розподілу VLAN-ів") { options[:table] = true }
475
575
  opts.on("-g", "--table-png PATH", "Зберегти діаграму розподілу VLAN-ів як PNG") { |path| options[:table_png] = path }
476
576
  opts.on("-i", "--interface INTERFACE", "Назва інтерфейсу або 'all'") { |i| options[:interface] = i }
577
+ opts.on("-c", "--config CONFIG_FILE", "Шлях до конфігураційного файлу") { |c| options[:config_file] = c }
477
578
  end.parse!
478
579
 
479
580
  if ARGV.empty?
480
581
  puts "Помилка: потрібно вказати IP-адресу або hostname роутера."
481
- puts "Використання: free-range <IP-адреса або hostname> [-u|--username USERNAME] [-p|--password PASSWORD] [-n|--no-color] [-d|--debug] [-t|--table] [--table-png PATH] [-i|--interface INTERFACE]"
482
- exit 1
483
- end
484
-
485
- username = options[:username] || ENV['WHOAMI']
486
- password = options[:password] || ENV['WHATISMYPASSWD']
487
- if username.nil? || password.nil?
488
- puts "Помилка: необхідно вказати ім'я користувача та пароль."
489
- puts "Використовуйте опції -u|--username і -p|--password або змінні оточення WHOAMI і WHATISMYPASSWD."
582
+ puts "Використовуйте: free-range --help для довідки."
490
583
  exit 1
491
584
  end
492
585
 
586
+ # Визначаємо змінні з опцій
493
587
  use_color = !options[:no_color] && ENV['TERM'] && ENV['TERM'] != 'dumb'
494
588
  debug = options[:debug]
495
589
  table_mode = options[:table]
496
590
  table_png_mode = options[:table_png]
497
591
  interface = options[:interface]
592
+ config_file = options[:config_file]
593
+
594
+ # Ініціалізуємо config з порожнім login
595
+ config = Config.new({ target: ARGV[0], username: nil, password: nil })
596
+
597
+ # Завантажуємо конфігураційний файл, якщо він вказаний
598
+ if config_file
599
+ begin
600
+ # Виконуємо конфігураційний файл у контексті існуючого об’єкта config
601
+ config.instance_eval(File.read(config_file), config_file)
602
+ rescue LoadError, Errno::ENOENT
603
+ puts "Помилка: неможливо завантажити конфігураційний файл '#{config_file}'."
604
+ exit 1
605
+ rescue ArgumentError => e
606
+ puts "Помилка в аргументах конфігураційного файлу '#{config_file}': #{e.message}"
607
+ exit 1
608
+ rescue StandardError => e
609
+ puts "Помилка в конфігураційному файлі '#{config_file}': #{e.message}"
610
+ exit 1
611
+ end
612
+ end
613
+
614
+ # Визначаємо username і password з пріоритетом: аргументи > config > ENV
615
+ username = options[:username] || config.username || ENV['WHOAMI']
616
+ password = options[:password] || config.password || ENV['WHATISMYPASSWD']
617
+
618
+ if username.nil? || password.nil?
619
+ puts "Помилка: необхідно вказати ім'я користувача та пароль."
620
+ puts "Використовуйте опції -u/--username і -p/--password, конфігураційний файл або змінні оточення WHOAMI і WHATISMYPASSWD."
621
+ exit 1
622
+ end
498
623
 
499
624
  login = { target: ARGV[0], username: username, password: password }
500
625
  target = ARGV[0].split('.')[0]
501
626
  puts "Connecting to device: #{login[:target]}"
502
627
 
503
- ssh_command = "sshpass -p \"#{login[:password]}\" ssh -C -x -4 -o StrictHostKeyChecking=no #{login[:username]}@#{login[:target]}"
504
- subscribers_command = "ssh -C -x roffice /usr/local/share/noc/bin/radius-subscribers"
628
+ # Оновлюємо config з актуальними login даними
629
+ config = Config.new(login) { |c|
630
+ c.username = config.username if config.username
631
+ c.password = config.password if config.password
632
+ }
633
+
634
+ if debug
635
+ puts "[DEBUG] Values:"
636
+ puts "[DEBUG] use_color: #{use_color}"
637
+ puts "[DEBUG] table_mode: #{table_mode}"
638
+ puts "[DEBUG] table_png_mode: #{table_png_mode}"
639
+ puts "[DEBUG] interface: #{interface}"
640
+ puts "[DEBUG] ARGV[0]: #{ARGV[0]}"
641
+ puts "[DEBUG] target: #{target}"
642
+ puts "[DEBUG] login: #{login}"
643
+ puts "[DEBUG] config_file: #{config_file}"
644
+ puts "[DEBUG] config.username: #{config.username}"
645
+ puts "[DEBUG] config.password: #{config.password}"
646
+ puts "[DEBUG] config.ssh_command: #{config.ssh_command}"
647
+ puts "[DEBUG] config.subscribers_command: #{config.subscribers_command}"
648
+ puts "[DEBUG] config.command_interfaces: #{config.command_interfaces}"
649
+ end
505
650
 
506
- subscribers_result = `#{subscribers_command}`.strip
651
+ subscribers_result = `#{config.subscribers_command}`.strip
507
652
  if subscribers_result.empty?
508
653
  puts "Помилка: результат subscribers_command порожній. Перевір шлях або доступ."
509
654
  exit 1
510
655
  end
511
656
 
512
657
  if interface == "all"
513
- command_interfaces = 'show configuration interfaces | no-more | display set | match dynamic-profile | match "ranges ([0-9]+(-[0-9]+)?)"'
514
- full_cmd = "#{ssh_command} '#{command_interfaces}'"
658
+ full_cmd = "#{config.ssh_command} '#{config.command_interfaces}'"
659
+ puts "[DEBUG] Executing command: #{full_cmd}" if debug
515
660
  result = `#{full_cmd}`.strip
516
661
  if result.empty?
517
662
  puts "Помилка: результат команди порожній. Перевір підключення або команду."
@@ -525,60 +670,10 @@ module FreeRange
525
670
  end
526
671
 
527
672
  interfaces.each do |intf|
528
- ranges = Ranges.new
529
- vlans = Vlans.new
530
- subscribers_result.each_line do |line|
531
- if line.split.first =~ /dhcp(?:_[0-9a-fA-F.]+)?_([^:]+):(\d+)@#{Regexp.escape(target)}$/
532
- subscriber_interface, vlan = $1, $2.to_i
533
- vlans.add_vlan(vlan) if subscriber_interface == intf && vlan > 0
534
- end
535
- end
536
-
537
- process_interface(ssh_command, intf, ranges)
538
- if debug
539
- puts "\nІнтерфейс: #{intf}"
540
- Print.ranged(ranges)
541
- Print.vlans(vlans)
542
- Print.vlan_ranges(vlans)
543
- puts
544
- end
545
- if table_png_mode
546
- Print.table_png(ranges, vlans, table_png_mode, target, intf)
547
- elsif table_mode
548
- Print.table(ranges, vlans, use_color, target, intf)
549
- else
550
- Print.combined_ranges(ranges, vlans, use_color, target, intf)
551
- end
673
+ process_and_output(config, intf, subscribers_result, target, use_color, debug, table_mode, table_png_mode)
552
674
  end
553
675
  else
554
- ranges = Ranges.new
555
- vlans = Vlans.new
556
- subscribers_result.each_line do |line|
557
- if line.split.first =~ /dhcp(?:_[0-9a-fA-F.]+)?_([^:]+):(\d+)@#{Regexp.escape(target)}$/
558
- subscriber_interface, vlan = $1, $2.to_i
559
- if interface
560
- vlans.add_vlan(vlan) if subscriber_interface == interface && vlan > 0
561
- else
562
- vlans.add_vlan(vlan) if vlan > 0
563
- end
564
- end
565
- end
566
-
567
- process_interface(ssh_command, interface, ranges)
568
- if debug
569
- puts "\nІнтерфейс: #{interface}" if interface
570
- Print.ranged(ranges)
571
- Print.vlans(vlans)
572
- Print.vlan_ranges(vlans)
573
- puts
574
- end
575
- if table_png_mode
576
- Print.table_png(ranges, vlans, table_png_mode, target, interface)
577
- elsif table_mode
578
- Print.table(ranges, vlans, use_color, target, interface)
579
- else
580
- Print.combined_ranges(ranges, vlans, use_color, target, interface)
581
- end
676
+ process_and_output(config, interface, subscribers_result, target, use_color, debug, table_mode, table_png_mode)
582
677
  end
583
678
  end
584
679
  end
@@ -0,0 +1,641 @@
1
+ require 'optparse'
2
+ require 'rmagick'
3
+ require 'fileutils'
4
+
5
+ module FreeRange
6
+ # Перевірка наявності ImageMagick
7
+ begin
8
+ require 'rmagick'
9
+ rescue LoadError
10
+ puts "Помилка: бібліотека rmagick не встановлена або ImageMagick недоступний."
11
+ puts "Встановіть ImageMagick і виконайте: gem install rmagick"
12
+ exit 1
13
+ end
14
+
15
+ # Клас для зберігання конфігураційних команд
16
+ class Config
17
+ attr_accessor :ssh_command, :subscribers_command
18
+
19
+ # Ініціалізує об’єкт конфігурації з значеннями за замовчуванням
20
+ # @param login [Hash] Hash with target, username, and password
21
+ # @yield [self] Yields self for block-based configuration
22
+ def initialize(login)
23
+ @login = login
24
+ # Значення за замовчуванням
25
+ @ssh_command = "sshpass -p \"#{@login[:password]}\" ssh -C -x -4 -o StrictHostKeyChecking=no #{@login[:username]}@#{@login[:target]}"
26
+ @subscribers_command = "ssh -C -x roffice /usr/local/share/noc/bin/radius-subscribers"
27
+ yield self if block_given?
28
+ end
29
+
30
+ # Повертає команду для отримання списку інтерфейсів
31
+ # @return [String] Command to fetch interfaces
32
+ def command_interfaces
33
+ 'show configuration interfaces | no-more | display set | match dynamic-profile | match "ranges ([0-9]+(-[0-9]+)?)"'
34
+ end
35
+
36
+ # Повертає команду для отримання діапазонів VLAN
37
+ # @param interface [String, nil] Interface name or nil for all interfaces
38
+ # @return [String] Command to fetch VLAN ranges
39
+ def command_ranges(interface)
40
+ interface ? "show configuration interfaces #{interface} | no-more | display set | match dynamic-profile | match \"ranges ([0-9]+(-[0-9]+)?)\"" : 'show configuration interfaces | no-more | display set | match dynamic-profile | match "ranges ([0-9]+(-[0-9]+)?)"'
41
+ end
42
+
43
+ # Повертає команду для отримання демультиплексорних VLAN
44
+ # @param interface [String, nil] Interface name or nil for all interfaces
45
+ # @return [String] Command to fetch demux VLANs
46
+ def command_demux(interface)
47
+ interface ? "show configuration interfaces #{interface} | display set | match unnumbered-address" : 'show configuration interfaces | display set | match unnumbered-address'
48
+ end
49
+
50
+ # Повертає команду для отримання інших VLAN
51
+ # @param interface [String, nil] Interface name or nil for all interfaces
52
+ # @return [String] Command to fetch other VLANs
53
+ def command_another(interface)
54
+ interface ? "show configuration interfaces #{interface} | display set | match vlan-id" : 'show configuration interfaces | display set | match vlan-id'
55
+ end
56
+ end
57
+
58
+ # Абстрактний клас для роботи з VLAN
59
+ class VlanContainer
60
+ def initialize
61
+ @vlans = []
62
+ end
63
+
64
+ # Додаємо VLAN до списку (віртуальний метод, може бути перевизначений)
65
+ # @param vlan [Integer] VLAN ID to add
66
+ # @return [void]
67
+ def add_vlan(vlan)
68
+ @vlans << vlan
69
+ end
70
+
71
+ # Повертаємо масив VLAN
72
+ # @return [Array<Integer>] Sorted array of unique VLAN IDs
73
+ def vlans
74
+ @vlans.uniq.sort
75
+ end
76
+
77
+ # Створюємо хеш діапазонів із VLAN
78
+ # @return [Hash{Integer => Integer}] Hash mapping range start to range end
79
+ def ranges
80
+ return {} if @vlans.empty?
81
+
82
+ vlan_ranges_hash = {}
83
+ sorted_vlans = vlans
84
+ start = sorted_vlans.first
85
+ prev = start
86
+
87
+ sorted_vlans[1..-1].each do |vlan|
88
+ unless vlan == prev + 1
89
+ vlan_ranges_hash[start] = prev
90
+ start = vlan
91
+ end
92
+ prev = vlan
93
+ end
94
+ vlan_ranges_hash[start] = prev
95
+
96
+ vlan_ranges_hash
97
+ end
98
+ end
99
+
100
+ # Клас для зберігання та обробки діапазонів
101
+ class Ranges < VlanContainer
102
+ def initialize
103
+ super
104
+ @another_in_ranges = []
105
+ end
106
+
107
+ # Додаємо діапазон до списку VLAN-ів
108
+ # @param start [Integer] Start of VLAN range
109
+ # @param finish [Integer] End of VLAN range
110
+ # @return [void]
111
+ def add_range(start, finish)
112
+ (start..finish).each { |vlan| @vlans << vlan }
113
+ end
114
+
115
+ # Додаємо діапазон до списку "інших" VLAN-ів
116
+ # @param start [Integer] Start of another VLAN range
117
+ # @param finish [Integer] End of another VLAN range
118
+ # @return [void]
119
+ def add_another_range(start, finish)
120
+ (start..finish).each { |vlan| @another_in_ranges << vlan }
121
+ end
122
+
123
+ # Повертаємо масив "інших" VLAN-ів
124
+ # @return [Array<Integer>] Sorted array of unique "another" VLAN IDs
125
+ def another_in_ranges
126
+ @another_in_ranges.uniq.sort
127
+ end
128
+ end
129
+
130
+ # Клас для зберігання та обробки VLAN-ів
131
+ class Vlans < VlanContainer
132
+ # Метод add_vlan уже успадковано від VlanContainer
133
+ # Метод vlans уже успадковано
134
+ # Метод ranges уже успадковано, але перейменуємо для зрозумілості
135
+ alias vlan_ranges ranges
136
+ end
137
+
138
+ # Клас для виводу даних
139
+ class Print
140
+ # Виводить хеш діапазонів VLAN у порядку зростання
141
+ # @param ranges [Ranges] Object containing VLAN ranges
142
+ # @return [void]
143
+ def self.ranged(ranges)
144
+ puts "\nСформований хеш діапазонів (в порядку зростання):"
145
+ if ranges.ranges.empty?
146
+ puts "Не знайдено діапазонів."
147
+ else
148
+ ranges.ranges.sort_by { |k, _| k }.each do |start, end_val|
149
+ puts "range[#{start}]=#{end_val}"
150
+ end
151
+ end
152
+ end
153
+
154
+ # Виводить зайняті VLAN-и в межах діапазонів у порядку зростання
155
+ # @param vlans [Vlans] Object containing VLANs
156
+ # @return [void]
157
+ def self.vlans(vlans)
158
+ puts "\nЗайняті VLAN-и в межах діапазонів (в порядку зростання):"
159
+ if vlans.vlans.empty?
160
+ puts "Не знайдено VLAN-ів у межах діапазонів."
161
+ else
162
+ puts vlans.vlans.uniq.sort.join(", ")
163
+ end
164
+ end
165
+
166
+ # Виводить діапазони VLAN-ів у порядку зростання
167
+ # @param vlans [Vlans] Object containing VLANs
168
+ # @return [void]
169
+ def self.vlan_ranges(vlans)
170
+ puts "\nДіапазони VLAN-ів (в порядку зростання):"
171
+ vlan_ranges = vlans.vlan_ranges
172
+ if vlan_ranges.empty?
173
+ puts "Не знайдено діапазонів VLAN-ів."
174
+ else
175
+ vlan_ranges.sort_by { |k, _| k }.each do |start, end_val|
176
+ puts "range[#{start}]=#{end_val}"
177
+ end
178
+ end
179
+ end
180
+
181
+ # Виводить комбіновані діапазони VLAN зі статусами
182
+ # @param ranges [Ranges] Object containing VLAN ranges
183
+ # @param vlans [Vlans] Object containing VLANs
184
+ # @param use_color [Boolean] Enable colored output
185
+ # @param target [String] Target device hostname
186
+ # @param interface [String, nil] Interface name or nil
187
+ # @return [void]
188
+ def self.combined_ranges(ranges, vlans, use_color, target, interface = nil)
189
+ if ranges.ranges.empty?
190
+ puts "Не знайдено діапазонів."
191
+ return
192
+ end
193
+
194
+ all_vlans, _status_counts = build_vlan_statuses(ranges, vlans)
195
+ result = []
196
+ sorted_vlans = all_vlans.keys.sort
197
+ start = sorted_vlans.first
198
+ prev = start
199
+ status = all_vlans[start]
200
+
201
+ sorted_vlans[1..-1].each do |vlan|
202
+ unless vlan == prev + 1 && all_vlans[vlan] == status
203
+ result << format_range(start, prev, status, use_color)
204
+ start = vlan
205
+ status = all_vlans[vlan]
206
+ end
207
+ prev = vlan
208
+ end
209
+ result << format_range(start, prev, status, use_color)
210
+
211
+ puts result.join(',')
212
+ end
213
+
214
+ # Виводить таблицю розподілу VLAN
215
+ # @param ranges [Ranges] Object containing VLAN ranges
216
+ # @param vlans [Vlans] Object containing VLANs
217
+ # @param use_color [Boolean] Enable colored output
218
+ # @param target [String] Target device hostname
219
+ # @param interface [String, nil] Interface name or nil
220
+ # @return [void]
221
+ def self.table(ranges, vlans, use_color, target, interface = nil)
222
+ puts "VLAN Distribution for #{target}#{interface ? " (#{interface})" : ''}"
223
+ all_vlans, status_counts = build_vlan_statuses(ranges, vlans)
224
+
225
+ puts " 0 1 2 3 4 5 6 7 8 9 "
226
+ (0..40).each do |h|
227
+ start_vlan = h * 100
228
+ end_vlan = [start_vlan + 99, 4094].min
229
+ row = (start_vlan..end_vlan).map { |vlan| format_table_char(all_vlans[vlan] || ' ', use_color) }.join
230
+ puts "#{format("%4d", start_vlan)} #{row}"
231
+ end
232
+
233
+ legend_parts = [
234
+ ["Legend: ", nil],
235
+ ["f", 'f'], ["=free", nil], [", ", nil],
236
+ ["b", 'b'], ["=busy", nil], [", ", nil],
237
+ ["e", 'e'], ["=error", nil], [", ", nil],
238
+ ["c", 'c'], ["=configured", nil], [", ", nil],
239
+ ["a", 'a'], ["=another", nil], [", ", nil],
240
+ ["u", 'u'], ["=unused", nil]
241
+ ]
242
+ legend_text = legend_parts.map do |text, status|
243
+ if status && use_color
244
+ format_table_char(status, use_color)
245
+ else
246
+ text
247
+ end
248
+ end.join
249
+ puts "\n#{legend_text}"
250
+
251
+ summary_parts = [
252
+ ["Total: ", nil],
253
+ ["f", 'f'], ["=#{status_counts['f']}", nil], [", ", nil],
254
+ ["b", 'b'], ["=#{status_counts['b']}", nil], [", ", nil],
255
+ ["e", 'e'], ["=#{status_counts['e']}", nil], [", ", nil],
256
+ ["c", 'c'], ["=#{status_counts['c']}", nil], [", ", nil],
257
+ ["a", 'a'], ["=#{status_counts['a']}", nil], [", ", nil],
258
+ ["u", 'u'], ["=#{status_counts['u']}", nil]
259
+ ]
260
+ summary_text = summary_parts.map do |text, status|
261
+ if status && use_color
262
+ format_table_char(status, use_color)
263
+ else
264
+ text
265
+ end
266
+ end.join
267
+ puts summary_text
268
+ end
269
+
270
+ # Зберігає таблицю розподілу VLAN як PNG-зображення
271
+ # @param ranges [Ranges] Object containing VLAN ranges
272
+ # @param vlans [Vlans] Object containing VLANs
273
+ # @param path [String] Directory path to save the PNG
274
+ # @param target [String] Target device hostname
275
+ # @param interface [String, nil] Interface name or nil
276
+ # @return [void]
277
+ def self.table_png(ranges, vlans, path, target, interface = nil)
278
+ all_vlans, status_counts = build_vlan_statuses(ranges, vlans)
279
+ cell_width = 12
280
+ cell_height = 20
281
+ rows = 41
282
+ cols = 100
283
+ header_height = 60
284
+ label_width = 50
285
+ width = label_width + cols * cell_width + 10
286
+ height = header_height + rows * cell_height + 20 + 50
287
+ font_size = 14
288
+ title_font_size = 18
289
+ font = 'Courier'
290
+
291
+ canvas = Magick::Image.new(width, height) { |options| options.background_color = 'white' }
292
+ gc = Magick::Draw.new
293
+ gc.font = font
294
+ gc.pointsize = font_size
295
+ gc.text_antialias = true
296
+
297
+ gc.fill('black')
298
+ gc.pointsize = title_font_size
299
+ gc.text(10, 25, "VLAN Distribution for #{target}#{interface ? " (#{interface})" : ''}")
300
+ gc.pointsize = font_size
301
+
302
+ (0..9).each do |i|
303
+ x = label_width + i * 10 * cell_width - 3
304
+ gc.fill('black')
305
+ gc.text(x + 5, header_height - 5, i.to_s)
306
+ end
307
+
308
+ (0..40).each do |h|
309
+ start_vlan = h * 100
310
+ end_vlan = [start_vlan + 99, 4094].min
311
+ y = header_height + h * cell_height
312
+ gc.fill('black')
313
+ gc.text(5, y + font_size, format("%4d", start_vlan))
314
+
315
+ (start_vlan..end_vlan).each_with_index do |vlan, i|
316
+ status = all_vlans[vlan] || ' '
317
+ x = label_width + i * cell_width
318
+ color = case status
319
+ when 'f' then '#00FF00' # Зелений
320
+ when 'b' then '#FFFF00' # Жовтий
321
+ when 'e' then '#FF0000' # Червоний
322
+ when 'c' then '#FF00FF' # Фіолетовий
323
+ when 'a' then '#0000FF' # Синій
324
+ when 'u' then '#555555' # Темно-сірий
325
+ else 'white' # Пробіл
326
+ end
327
+ gc.fill(color)
328
+ gc.rectangle(x, y, x + cell_width - 1, y + cell_height - 1)
329
+ gc.fill('black')
330
+ gc.text(x + 2, y + font_size, status) unless status == ' '
331
+ end
332
+ end
333
+
334
+ legend_y = height - 50
335
+ x = 10
336
+ legend_parts = [
337
+ ["Legend: ", nil],
338
+ ["f", '#00FF00'], ["=free", nil], [", ", nil],
339
+ ["b", '#FFFF00'], ["=busy", nil], [", ", nil],
340
+ ["e", '#FF0000'], ["=error", nil], [", ", nil],
341
+ ["c", '#FF00FF'], ["=configured", nil], [", ", nil],
342
+ ["a", '#0000FF'], ["=another", nil], [", ", nil],
343
+ ["u", '#555555'], ["=unused", nil]
344
+ ]
345
+ legend_parts.each do |text, color|
346
+ if color
347
+ gc.fill(color)
348
+ gc.rectangle(x, legend_y - font_size + 2, x + 10, legend_y + 2)
349
+ gc.fill('black')
350
+ gc.text(x + 2, legend_y, text)
351
+ x += 12
352
+ else
353
+ gc.fill('black')
354
+ gc.text(x, legend_y, text)
355
+ x += text.length * 8
356
+ end
357
+ end
358
+
359
+ summary_y = height - 30
360
+ x = 10
361
+ summary_parts = [
362
+ ["Total: ", nil],
363
+ ["f", '#00FF00'], ["=#{status_counts['f']}", nil], [", ", nil],
364
+ ["b", '#FFFF00'], ["=#{status_counts['b']}", nil], [", ", nil],
365
+ ["e", '#FF0000'], ["=#{status_counts['e']}", nil], [", ", nil],
366
+ ["c", '#FF00FF'], ["=#{status_counts['c']}", nil], [", ", nil],
367
+ ["a", '#0000FF'], ["=#{status_counts['a']}", nil], [", ", nil],
368
+ ["u", '#555555'], ["=#{status_counts['u']}", nil]
369
+ ]
370
+ summary_parts.each do |text, color|
371
+ if color
372
+ gc.fill(color)
373
+ gc.rectangle(x, summary_y - font_size + 2, x + 10, summary_y + 2)
374
+ gc.fill('black')
375
+ gc.text(x + 2, summary_y, text)
376
+ x += 12
377
+ else
378
+ gc.fill('black')
379
+ gc.text(x, summary_y, text)
380
+ x += text.length * 8
381
+ end
382
+ end
383
+
384
+ gc.draw(canvas)
385
+ FileUtils.mkdir_p(path) unless Dir.exist?(path)
386
+ filename = File.join(path, "free-range-#{target}#{interface ? "-#{interface.tr('/', '-')}" : ''}.png")
387
+ canvas.write(filename)
388
+ puts "Зображення збережено: #{filename}"
389
+ end
390
+
391
+ private
392
+
393
+ # Будує хеш статусів VLAN і підраховує кількість кожного статусу
394
+ # @param ranges [Ranges] Object containing VLAN ranges
395
+ # @param vlans [Vlans] Object containing VLANs
396
+ # @return [Array<Hash, Hash>] Hash of VLAN statuses and status counts
397
+ def self.build_vlan_statuses(ranges, vlans)
398
+ all_vlans = {}
399
+ ranges.ranges.each do |start, finish|
400
+ (start..finish).each { |vlan| all_vlans[vlan] = 'f' }
401
+ end
402
+ vlans.vlans.uniq.each { |vlan| all_vlans[vlan] = all_vlans.key?(vlan) ? 'b' : 'e' }
403
+ (1..4094).each { |vlan| all_vlans[vlan] = 'u' unless all_vlans.key?(vlan) }
404
+ ranges.another_in_ranges.each { |vlan| all_vlans[vlan] = all_vlans.key?(vlan) && all_vlans[vlan] != 'u' ? 'c' : 'a' }
405
+ status_counts = { 'f' => 0, 'b' => 0, 'e' => 0, 'c' => 0, 'a' => 0, 'u' => 0 }
406
+ all_vlans.each_value { |status| status_counts[status] += 1 }
407
+
408
+ [all_vlans, status_counts]
409
+ end
410
+
411
+ # Форматує діапазон VLAN зі статусом для виводу
412
+ # @param start [Integer] Start of VLAN range
413
+ # @param finish [Integer] End of VLAN range
414
+ # @param status [String] VLAN status ('f', 'b', 'e', 'c', 'a', 'u')
415
+ # @param use_color [Boolean] Enable colored output
416
+ # @return [String] Formatted range string
417
+ def self.format_range(start, finish, status, use_color)
418
+ range_text = start == finish ? "#{start}" : "#{start}-#{finish}"
419
+ range_text_with_status = "#{range_text}(#{status})"
420
+ if use_color
421
+ case status
422
+ when 'f' then "\e[32m#{range_text}\e[0m" # Зелений для free
423
+ when 'b' then "\e[33m#{range_text}\e[0m" # Жовтий для busy
424
+ when 'e' then "\e[31m#{range_text}\e[0m" # Червоний для error
425
+ when 'c' then "\e[35m#{range_text}\e[0m" # Фіолетовий для configured
426
+ when 'a' then "\e[34m#{range_text}\e[0m" # Синій для another
427
+ when 'u' then "\e[90m#{range_text}\e[0m" # Темно-сірий для unused
428
+ else range_text # Без кольору для інших статусів
429
+ end
430
+ else
431
+ range_text_with_status # Текстовий вивід зі статусами
432
+ end
433
+ end
434
+
435
+ # Форматує символ для таблиці VLAN
436
+ # @param status [String] VLAN status ('f', 'b', 'e', 'c', 'a', 'u', or ' ')
437
+ # @param use_color [Boolean] Enable colored output
438
+ # @return [String] Formatted character for table display
439
+ def self.format_table_char(status, use_color)
440
+ if use_color
441
+ case status
442
+ when 'f' then "\e[48;5;2m\e[30m#{status}\e[0m" # Зелений фон, чорний текст
443
+ when 'b' then "\e[48;5;3m\e[30m#{status}\e[0m" # Жовтий фон, чорний текст
444
+ when 'e' then "\e[48;5;1m\e[30m#{status}\e[0m" # Червоний фон, чорний текст
445
+ when 'c' then "\e[48;5;5m\e[30m#{status}\e[0m" # Фіолетовий фон, чорний текст
446
+ when 'a' then "\e[48;5;4m\e[30m#{status}\e[0m" # Синій фон, чорний текст
447
+ when 'u' then "\e[48;5;8m\e[30m#{status}\e[0m" # Темно-сірий фон, чорний текст
448
+ else status # Без кольору для інших статусів
449
+ end
450
+ else
451
+ status # Текстовий вивід без кольорів
452
+ end
453
+ end
454
+ end
455
+
456
+ # Метод для заповнення Ranges для одного інтерфейсу
457
+ # @param config [Config] Configuration object with commands
458
+ # @param interface [String, nil] Interface name or nil for all interfaces
459
+ # @param ranges [Ranges] Object to store VLAN ranges
460
+ # @return [void]
461
+ def self.process_interface(config, interface, ranges)
462
+ full_cmd = "#{config.ssh_command} '#{config.command_ranges(interface)}'"
463
+ result = `#{full_cmd}`.strip
464
+ unless result.empty?
465
+ result.each_line do |line|
466
+ if line =~ /ranges (\d+)(?:-(\d+))?/
467
+ start_range = $1.to_i
468
+ end_range = $2 ? $2.to_i : $1.to_i
469
+ ranges.add_range(start_range, end_range)
470
+ end
471
+ end
472
+ end
473
+
474
+ full_cmd = "#{config.ssh_command} '#{config.command_demux(interface)}'"
475
+ result = `#{full_cmd}`.strip
476
+ unless result.empty?
477
+ result.each_line do |line|
478
+ if line =~ /unit (\d+)/
479
+ start_range = $1.to_i
480
+ if start_range > 0
481
+ end_range = start_range
482
+ ranges.add_range(start_range, end_range)
483
+ end
484
+ end
485
+ end
486
+ end
487
+
488
+ full_cmd = "#{config.ssh_command} '#{config.command_another(interface)}'"
489
+ result = `#{full_cmd}`.strip
490
+ unless result.empty?
491
+ result.each_line do |line|
492
+ if line =~ /vlan-id (\d+)/
493
+ start_range = $1.to_i
494
+ if start_range > 0
495
+ end_range = start_range
496
+ ranges.add_another_range(start_range, end_range)
497
+ end
498
+ end
499
+ end
500
+ end
501
+ end
502
+
503
+ # Основна логіка виконання
504
+ # @return [void]
505
+ def self.run
506
+ options = {}
507
+ OptionParser.new do |opts|
508
+ opts.banner = <<~BANNER
509
+ Використання: free-range <IP-адреса або hostname> [опції]
510
+
511
+ Аналізує розподіл VLAN на мережевих пристроях, генеруючи таблиці або PNG-зображення.
512
+ BANNER
513
+ opts.on("-h", "--help", "Показати цю довідку") { puts opts; exit 0 }
514
+ opts.on("-u", "--username USERNAME", "Ім'я користувача для SSH") { |u| options[:username] = u }
515
+ opts.on("-p", "--password PASSWORD", "Пароль для SSH") { |p| options[:password] = p }
516
+ opts.on("-n", "--no-color", "Вимкнути кольоровий вивід") { options[:no_color] = true }
517
+ opts.on("-d", "--debug", "Увімкнути дебаг-режим") { options[:debug] = true }
518
+ opts.on("-t", "--table", "Вивести діаграму розподілу VLAN-ів") { options[:table] = true }
519
+ opts.on("-g", "--table-png PATH", "Зберегти діаграму розподілу VLAN-ів як PNG") { |path| options[:table_png] = path }
520
+ opts.on("-i", "--interface INTERFACE", "Назва інтерфейсу або 'all'") { |i| options[:interface] = i }
521
+ opts.on("-c", "--config CONFIG_FILE", "Шлях до конфігураційного файлу") { |c| options[:config_file] = c }
522
+ end.parse!
523
+
524
+ if ARGV.empty?
525
+ puts "Помилка: потрібно вказати IP-адресу або hostname роутера."
526
+ puts "Використовуйте: free-range --help для довідки."
527
+ exit 1
528
+ end
529
+
530
+ username = options[:username] || ENV['WHOAMI']
531
+ password = options[:password] || ENV['WHATISMYPASSWD']
532
+ if username.nil? || password.nil?
533
+ puts "Помилка: необхідно вказати ім'я користувача та пароль."
534
+ puts "Використовуйте опції -u/--username і -p/--password або змінні оточення WHOAMI і WHATISMYPASSWD."
535
+ exit 1
536
+ end
537
+
538
+ use_color = !options[:no_color] && ENV['TERM'] && ENV['TERM'] != 'dumb'
539
+ debug = options[:debug]
540
+ table_mode = options[:table]
541
+ table_png_mode = options[:table_png]
542
+ interface = options[:interface]
543
+ config_file = options[:config_file]
544
+
545
+ login = { target: ARGV[0], username: username, password: password }
546
+ target = ARGV[0].split('.')[0]
547
+ puts "Connecting to device: #{login[:target]}"
548
+
549
+ # Ініціалізація конфігурації
550
+ config = Config.new(login)
551
+ if config_file
552
+ begin
553
+ load config_file
554
+ config = FreeRange::Config.new(login) { |c| eval(File.read(config_file), binding, config_file) }
555
+ rescue LoadError, Errno::ENOENT
556
+ puts "Помилка: неможливо завантажити конфігураційний файл '#{config_file}'."
557
+ exit 1
558
+ rescue StandardError => e
559
+ puts "Помилка в конфігураційному файлі '#{config_file}': #{e.message}"
560
+ exit 1
561
+ end
562
+ end
563
+
564
+ subscribers_result = `#{config.subscribers_command}`.strip
565
+ if subscribers_result.empty?
566
+ puts "Помилка: результат subscribers_command порожній. Перевір шлях або доступ."
567
+ exit 1
568
+ end
569
+
570
+ if interface == "all"
571
+ full_cmd = "#{config.ssh_command} '#{config.command_interfaces}'"
572
+ result = `#{full_cmd}`.strip
573
+ if result.empty?
574
+ puts "Помилка: результат команди порожній. Перевір підключення або команду."
575
+ exit 1
576
+ end
577
+
578
+ interfaces = result.each_line.map { |line| line.split[2] }.uniq
579
+ if interfaces.empty?
580
+ puts "Помилка: не знайдено інтерфейсів із діапазонами."
581
+ exit 1
582
+ end
583
+
584
+ interfaces.each do |intf|
585
+ ranges = Ranges.new
586
+ vlans = Vlans.new
587
+ subscribers_result.each_line do |line|
588
+ if line.split.first =~ /dhcp(?:_[0-9a-fA-F.]+)?_([^:]+):(\d+)@#{Regexp.escape(target)}$/
589
+ subscriber_interface, vlan = $1, $2.to_i
590
+ vlans.add_vlan(vlan) if subscriber_interface == intf && vlan > 0
591
+ end
592
+ end
593
+
594
+ process_interface(config, intf, ranges)
595
+ if debug
596
+ puts "\nІнтерфейс: #{intf}"
597
+ Print.ranged(ranges)
598
+ Print.vlans(vlans)
599
+ Print.vlan_ranges(vlans)
600
+ puts
601
+ end
602
+ if table_png_mode
603
+ Print.table_png(ranges, vlans, table_png_mode, target, intf)
604
+ elsif table_mode
605
+ Print.table(ranges, vlans, use_color, target, intf)
606
+ else
607
+ Print.combined_ranges(ranges, vlans, use_color, target, intf)
608
+ end
609
+ end
610
+ else
611
+ ranges = Ranges.new
612
+ vlans = Vlans.new
613
+ subscribers_result.each_line do |line|
614
+ if line.split.first =~ /dhcp(?:_[0-9a-fA-F.]+)?_([^:]+):(\d+)@#{Regexp.escape(target)}$/
615
+ subscriber_interface, vlan = $1, $2.to_i
616
+ if interface
617
+ vlans.add_vlan(vlan) if subscriber_interface == interface && vlan > 0
618
+ else
619
+ vlans.add_vlan(vlan) if vlan > 0
620
+ end
621
+ end
622
+ end
623
+
624
+ process_interface(config, interface, ranges)
625
+ if debug
626
+ puts "\nІнтерфейс: #{interface}" if interface
627
+ Print.ranged(ranges)
628
+ Print.vlans(vlans)
629
+ Print.vlan_ranges(vlans)
630
+ puts
631
+ end
632
+ if table_png_mode
633
+ Print.table_png(ranges, vlans, table_png_mode, target, interface)
634
+ elsif table_mode
635
+ Print.table(ranges, vlans, use_color, target, interface)
636
+ else
637
+ Print.combined_ranges(ranges, vlans, use_color, target, interface)
638
+ end
639
+ end
640
+ end
641
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: free-range
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleksandr Russkikh //aka Olden Gremlin
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.9'
55
69
  description: A Ruby script to analyze VLAN distribution on network devices, generating
56
70
  tables or PNG images.
57
71
  email: olden@ukr-com.net
@@ -62,6 +76,7 @@ extra_rdoc_files: []
62
76
  files:
63
77
  - bin/free-range
64
78
  - lib/free-range.rb
79
+ - lib/free-range.rb.~
65
80
  - lib/free-range.rb~
66
81
  homepage: https://github.com/oldengremlin/free-range
67
82
  licenses: