free-range 0.1.3 → 0.2.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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/free-range.rb +113 -26
  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: a0252edadec09077b1c95f148f2e5dec18a0faf393f7c9b225fc0ca0962b06af
4
+ data.tar.gz: f45851705405c80dd7ec484c439711716146317e0b19efdba3e4d756f5fce2fa
5
5
  SHA512:
6
- metadata.gz: ef260cde29dfee53e9ab895c14a232c0e023052cb2c614e2ce3e7d34ffbdefebabbe856c2fbaa143179b7fa387468dd32d89718da69ab22d669230fd2a164a76
7
- data.tar.gz: 2477e3ea176e80ceb40869bf1e2835ffd63b4a8e733fb87d3f4497bcc70d27d74814f94586e946b2c5b24f8db9f7359f4bb60c7a3087078927d608d03234915d
6
+ metadata.gz: 76e14e7c6e5a94c2f4a88fe987477e8ca960aab236fb2d63b24e7655a694d5e0da57a0b08aee141c4f6b4ecd4e1ec2caf64d671703033ab626a725de30c4a9a5
7
+ data.tar.gz: 879632d0abb8539b61b99e49af8bf427b786163be9e669e0a5542f2286234666d9c183a360fe21cac97bc9debb1ad97e816b15f18bd1cb494adfb205252c43ad
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|
@@ -466,7 +520,12 @@ module FreeRange
466
520
  def self.run
467
521
  options = {}
468
522
  OptionParser.new do |opts|
469
- opts.banner = "Використання: free-range <IP-адреса або hostname> [опції]"
523
+ opts.banner = <<~BANNER
524
+ Використання: free-range <IP-адреса або hostname> [опції]
525
+
526
+ Аналізує розподіл VLAN на мережевих пристроях, генеруючи таблиці або PNG-зображення.
527
+ BANNER
528
+ opts.on("-h", "--help", "Показати цю довідку") { puts opts; exit 0 }
470
529
  opts.on("-u", "--username USERNAME", "Ім'я користувача для SSH") { |u| options[:username] = u }
471
530
  opts.on("-p", "--password PASSWORD", "Пароль для SSH") { |p| options[:password] = p }
472
531
  opts.on("-n", "--no-color", "Вимкнути кольоровий вивід") { options[:no_color] = true }
@@ -474,44 +533,72 @@ module FreeRange
474
533
  opts.on("-t", "--table", "Вивести діаграму розподілу VLAN-ів") { options[:table] = true }
475
534
  opts.on("-g", "--table-png PATH", "Зберегти діаграму розподілу VLAN-ів як PNG") { |path| options[:table_png] = path }
476
535
  opts.on("-i", "--interface INTERFACE", "Назва інтерфейсу або 'all'") { |i| options[:interface] = i }
536
+ opts.on("-c", "--config CONFIG_FILE", "Шлях до конфігураційного файлу") { |c| options[:config_file] = c }
477
537
  end.parse!
478
538
 
479
539
  if ARGV.empty?
480
540
  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."
541
+ puts "Використовуйте: free-range --help для довідки."
490
542
  exit 1
491
543
  end
492
544
 
545
+ # Визначаємо змінні з опцій
493
546
  use_color = !options[:no_color] && ENV['TERM'] && ENV['TERM'] != 'dumb'
494
547
  debug = options[:debug]
495
548
  table_mode = options[:table]
496
549
  table_png_mode = options[:table_png]
497
550
  interface = options[:interface]
551
+ config_file = options[:config_file]
552
+
553
+ # Ініціалізуємо config з порожнім login
554
+ config = Config.new({ target: ARGV[0], username: nil, password: nil })
555
+
556
+ # Завантажуємо конфігураційний файл, якщо він вказаний
557
+ if config_file
558
+ begin
559
+ # Виконуємо конфігураційний файл у контексті існуючого об’єкта config
560
+ config.instance_eval(File.read(config_file), config_file)
561
+ rescue LoadError, Errno::ENOENT
562
+ puts "Помилка: неможливо завантажити конфігураційний файл '#{config_file}'."
563
+ exit 1
564
+ rescue ArgumentError => e
565
+ puts "Помилка в конфігураційному файлі '#{config_file}': #{e.message}"
566
+ exit 1
567
+ rescue StandardError => e
568
+ puts "Помилка в конфігураційному файлі '#{config_file}': #{e.message}"
569
+ exit 1
570
+ end
571
+ end
572
+
573
+ # Визначаємо username і password з пріоритетом: аргументи > config > ENV
574
+ username = options[:username] || config.username || ENV['WHOAMI']
575
+ password = options[:password] || config.password || ENV['WHATISMYPASSWD']
576
+
577
+ if username.nil? || password.nil?
578
+ puts "Помилка: необхідно вказати ім'я користувача та пароль."
579
+ puts "Використовуйте опції -u/--username і -p/--password, конфігураційний файл або змінні оточення WHOAMI і WHATISMYPASSWD."
580
+ exit 1
581
+ end
498
582
 
499
583
  login = { target: ARGV[0], username: username, password: password }
500
584
  target = ARGV[0].split('.')[0]
501
585
  puts "Connecting to device: #{login[:target]}"
502
586
 
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"
587
+ # Оновлюємо config з актуальними login даними
588
+ config = Config.new(login) { |c|
589
+ c.username = config.username if config.username
590
+ c.password = config.password if config.password
591
+ }
505
592
 
506
- subscribers_result = `#{subscribers_command}`.strip
593
+ subscribers_result = `#{config.subscribers_command}`.strip
507
594
  if subscribers_result.empty?
508
595
  puts "Помилка: результат subscribers_command порожній. Перевір шлях або доступ."
509
596
  exit 1
510
597
  end
511
598
 
512
599
  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}'"
600
+ full_cmd = "#{config.ssh_command} '#{config.command_interfaces}'"
601
+ puts "[DEBUG] Executing command: #{full_cmd}" if debug
515
602
  result = `#{full_cmd}`.strip
516
603
  if result.empty?
517
604
  puts "Помилка: результат команди порожній. Перевір підключення або команду."
@@ -534,7 +621,7 @@ module FreeRange
534
621
  end
535
622
  end
536
623
 
537
- process_interface(ssh_command, intf, ranges)
624
+ process_interface(config, intf, ranges, debug)
538
625
  if debug
539
626
  puts "\nІнтерфейс: #{intf}"
540
627
  Print.ranged(ranges)
@@ -564,7 +651,7 @@ module FreeRange
564
651
  end
565
652
  end
566
653
 
567
- process_interface(ssh_command, interface, ranges)
654
+ process_interface(config, interface, ranges, debug)
568
655
  if debug
569
656
  puts "\nІнтерфейс: #{interface}" if interface
570
657
  Print.ranged(ranges)
@@ -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.0
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: