panda-motd 0.0.6 → 0.0.12

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA256:
3
- metadata.gz: b5971befc8f39c5194983b38abb1918e56636c6a35a71e2dc42243983fd6a426
4
- data.tar.gz: 7a4c4206471c1ad63dcb231e742d447d415c6ab13f6f1334073d218268a4d89a
2
+ SHA1:
3
+ metadata.gz: 5e27df79cdab81d15c44ff575075e876eb975b44
4
+ data.tar.gz: 954bf7eb0ff70fd62b86918dfe1cbd95c84d56bb
5
5
  SHA512:
6
- metadata.gz: 7d426d6dffe2c208109ad099e6fdb68891e59ae07f42c9cdb80de23f9041776862cf102c9d913441692ddb96ccb1d392084cc473629f387e8165a4c4581160f3
7
- data.tar.gz: baf84a939e6a697b6af473b3bf76d216c2e957d15eb67221c07fad3b0f917ccc4698ab7d54e03ed2d00da65f01eadb1fa8098f5285992c074085e5ef2d7a60f5
6
+ metadata.gz: af66d7e99d7396984468e5824b1902b1e595aea9022a27c1c128df98416f386c89f23f1c5415aca4da26045af458e4b6e5b5844111a3488aabb1e15270d79351
7
+ data.tar.gz: 306ec97a05b35bfb2a0418018fbfc1b627c0e69007bef88ac7932b29bb319418637f416ec2c3903de1ddfcd44f29e6abe413944b153ec85fc04669177ffbd4fc
data/bin/panda-motd CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'panda_motd'
3
+ require "panda_motd"
4
4
 
5
5
  motd = PandaMOTD.new_motd
6
6
  puts motd
data/lib/panda_motd.rb CHANGED
@@ -1,12 +1,21 @@
1
- require 'require_all'
2
- require_rel 'panda_motd'
1
+ # frozen_string_literal: true
2
+
3
+ require "require_all"
4
+ require_rel "panda_motd"
3
5
 
4
6
  class PandaMOTD
7
+ # Creates a new MOTD instance, assuming a config file has been passed as an
8
+ # argument to the command.
5
9
  def self.new_motd
6
10
  if ARGV[0].nil?
7
- puts 'You must provide a config file path as an argument to panda-motd.'
11
+ puts "You must provide a config file path as an argument to panda-motd."
8
12
  else
9
- return MOTD.new(ARGV[0])
13
+ MOTD.new(ARGV[0])
10
14
  end
11
15
  end
16
+
17
+ # Gets the root path of the gem.
18
+ def self.root
19
+ File.expand_path("..", __dir__)
20
+ end
12
21
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Component
4
+ attr_reader :name, :errors, :results, :config
5
+
6
+ def initialize(motd, name)
7
+ @name = name
8
+ @motd = motd
9
+ @config = motd.config.component_config(@name)
10
+ @errors = []
11
+ end
12
+
13
+ # Evaluates the component so that it has some meaningful output when it comes
14
+ # time to print the MOTD.
15
+ def process
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # Gives the output of a component as a string.
20
+ def to_s
21
+ raise NotImplementedError
22
+ end
23
+
24
+ # The number of lines to print before the component in the context of the
25
+ # entire MOTD. 1 by default, if not configured.
26
+ def lines_before
27
+ @motd.config.component_config(@name)["lines_before"] || 1
28
+ end
29
+
30
+ # The number of lines to print after the component in the context of the
31
+ # entire MOTD. 1 by default, if not configured.
32
+ def lines_after
33
+ @motd.config.component_config(@name)["lines_after"] || 1
34
+ end
35
+ end
@@ -1,4 +1,6 @@
1
- require 'colorize'
1
+ # frozen_string_literal: true
2
+
3
+ require "colorize"
2
4
 
3
5
  class ComponentError
4
6
  attr_reader :component, :message
@@ -8,6 +10,7 @@ class ComponentError
8
10
  @message = message
9
11
  end
10
12
 
13
+ # Gets a printable error string in red.
11
14
  def to_s
12
15
  return "#{@component.name} error: ".red + @message.to_s
13
16
  end
@@ -1,29 +1,23 @@
1
- require 'artii'
2
- require 'colorize'
1
+ # frozen_string_literal: true
3
2
 
4
- class ASCIITextArt
5
- attr_reader :name, :errors, :results
3
+ require "artii"
4
+ require "colorize"
6
5
 
6
+ class ASCIITextArt < Component
7
7
  def initialize(motd)
8
- @name = 'ascii_text_art'
9
- @motd = motd
10
- @config = motd.config.component_config(@name)
11
- @errors = []
8
+ super(motd, "ascii_text_art")
12
9
  end
13
10
 
14
11
  def process
15
- @text = `hostname`
16
-
17
- begin
18
- @art = Artii::Base.new font: @config['font']
19
- @results = @art.asciify(@text)
20
- @results = @results.send(@config['color'].to_sym) if @config['color']
21
- rescue Errno::EISDIR # Artii doesn't handle invalid font names very well
22
- @errors << ComponentError.new(self, 'Invalid font name')
23
- end
12
+ @text = `#{@config["command"]}`
13
+ @art = Artii::Base.new font: @config["font"]
14
+ @results = @art.asciify(@text)
15
+ @results = @results.colorize(@config["color"].to_sym) if @config["color"]
16
+ rescue Errno::EISDIR # Artii doesn't handle invalid font names very well
17
+ @errors << ComponentError.new(self, "Invalid font name")
24
18
  end
25
19
 
26
20
  def to_s
27
- return @results
21
+ @results
28
22
  end
29
23
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Fail2Ban < Component
4
+ def initialize(motd)
5
+ super(motd, "fail_2_ban")
6
+ end
7
+
8
+ def process
9
+ @results = {
10
+ jails: {},
11
+ }
12
+
13
+ @config["jails"].each do |jail|
14
+ status = jail_status(jail)
15
+ @results[:jails][jail] = {
16
+ total: status[:total],
17
+ current: status[:current],
18
+ }
19
+ end
20
+ end
21
+
22
+ def to_s
23
+ result = +"Fail2Ban:\n"
24
+ @results[:jails].each do |name, stats|
25
+ result << " #{name}:\n"
26
+ result << " Total bans: #{stats[:total]}\n"
27
+ result << " Current bans: #{stats[:current]}\n"
28
+ end
29
+
30
+ result.gsub!(/\s$/, "")
31
+ end
32
+
33
+ private
34
+
35
+ def jail_status(jail)
36
+ cmd_result = `fail2ban-client status #{jail}`
37
+ if cmd_result =~ /Sorry but the jail '#{jail}' does not exist/
38
+ @errors << ComponentError.new(self, "Invalid jail name '#{jail}'.")
39
+ else
40
+ total = cmd_result.match(/Total banned:\s+([0-9]+)/)[1].to_i
41
+ current = cmd_result.match(/Currently banned:\s+([0-9]+)/)[1].to_i
42
+ end
43
+
44
+ { total: total, current: current }
45
+ end
46
+ end
@@ -1,121 +1,137 @@
1
- require 'ruby-units'
2
- require 'colorize'
1
+ # frozen_string_literal: true
3
2
 
4
- class Filesystems
5
- attr_reader :name, :errors
6
- attr_reader :results
3
+ require "ruby-units"
4
+ require "colorize"
7
5
 
6
+ class Filesystems < Component
8
7
  def initialize(motd)
9
- @name = 'filesystems'
10
- @motd = motd
11
- @config = motd.config.component_config(@name)
12
- @errors = []
8
+ super(motd, "filesystems")
13
9
  end
14
10
 
15
11
  def process
16
- @results = parse_filesystem_usage(@config['filesystems'])
12
+ @results = parse_filesystem_usage(@config["filesystems"])
17
13
  end
18
14
 
19
15
  def to_s
20
- name_col_size = @results.select { |r| r.is_a? Hash }.map { |r| r[:pretty_name].length }.max
21
- name_col_size = 0 if name_col_size.nil?
16
+ name_col_size = @results.select { |r| r.is_a? Hash }
17
+ .map { |r| r[:pretty_name].length }
18
+ .max || 0
22
19
 
23
- name_col_size_with_padding = (name_col_size + 6) > 13 ? (name_col_size + 6) : 13
20
+ size_w_padding = (name_col_size + 6) > 13 ? (name_col_size + 6) : 13
24
21
 
25
- result = 'Filesystems'.ljust(name_col_size_with_padding, ' ')
26
- result += "Size Used Free Use%\n"
22
+ result = +"Filesystems".ljust(size_w_padding, " ")
23
+ result << "Size Used Free Use%\n"
27
24
 
28
25
  @results.each do |filesystem|
29
- # handle filesystem not found
30
- if filesystem.is_a? String
31
- result += " #{filesystem}\n"
32
- next
33
- end
34
-
35
- result += " #{filesystem[:pretty_name]}".ljust(name_col_size_with_padding, ' ')
36
-
37
- [:size, :used, :avail].each do |metric|
38
- units = if filesystem[metric] > 10**12
39
- 'terabytes'
40
- elsif filesystem[metric] > 10**9
41
- 'gigabytes'
42
- elsif filesystem[metric] > 10**6
43
- 'megabytes'
44
- elsif filesystem[metric] > 10**3
45
- 'kilobytes'
46
- else
47
- 'bytes'
48
- end
49
-
50
- value = Unit.new("#{filesystem[metric]} bytes").convert_to(units)
51
-
52
- # we have 4 characters of space to include the number, a potential decimal point, and
53
- # the unit character at the end. if the whole number component is 3+ digits long then
54
- # we omit the decimal point and just display the whole number component. if the whole
55
- # number component is 2 digits long, we can't afford to use a decimal point, so we still
56
- # only display the whole number component. if the whole number component is 1 digit long,
57
- # we use the single whole number component digit, a decimal point, a single fractional
58
- # digit, and the unit character.
59
- whole_number_length = value.scalar.floor.to_s.length
60
- round_amount = whole_number_length > 1 ? 0 : 1
61
- formatted_value = value.scalar.round(round_amount).to_s + units[0].upcase
62
- result += formatted_value.rjust(4, ' ') + ' '
63
- end
64
-
65
- percentage_used = ((filesystem[:used].to_f / filesystem[:size].to_f) * 100).round
66
- result += (percentage_used.to_s.rjust(3, ' ') + '%').send(percentage_color(percentage_used))
67
-
68
- result += "\n"
69
-
70
- total_ticks = name_col_size_with_padding + 18
71
- used_ticks = (total_ticks * (percentage_used.to_f / 100)).round
72
-
73
- result += " [#{('=' * used_ticks).send(percentage_color(percentage_used))}#{('=' * (total_ticks - used_ticks)).light_black}]"
74
- result += "\n" unless filesystem == @results.last
26
+ result << format_filesystem(filesystem, size_w_padding)
75
27
  end
76
28
 
77
- return result
29
+ result
78
30
  end
79
31
 
80
32
  private
81
33
 
82
- def percentage_color(percentage)
83
- return :green if (0..75).cover? percentage
84
- return :yellow if (75..95).cover? percentage
85
- return :red if (95..100).cover? percentage
86
- return :white
34
+ def format_filesystem(filesystem, size)
35
+ return " #{filesystem}\n" if filesystem.is_a? String # handle fs not found
36
+
37
+ # filesystem name
38
+ result = +""
39
+ result << " #{filesystem[:pretty_name]}".ljust(size, " ")
40
+
41
+ # statistics (size, used, free, use%)
42
+ [:size, :used, :avail].each do |metric|
43
+ result << format_metric(filesystem, metric)
44
+ end
45
+
46
+ percent_used = calc_percent_used(filesystem)
47
+ result << format_percent_used(percent_used)
48
+ result << "\n"
49
+
50
+ # visual bar representation of use%
51
+ result << generate_usage_bar(filesystem, size, percent_used)
52
+
53
+ result
87
54
  end
88
55
 
89
- def find_header_id_by_text(header_array, text)
90
- return header_array.each_index.select { |i| header_array[i].downcase.include? text }.first
56
+ def generate_usage_bar(filesystem, size, percent_used)
57
+ result = +""
58
+ total_ticks = size + 18
59
+ used_ticks = (total_ticks * (percent_used.to_f / 100)).round
60
+ result << " [#{("=" * used_ticks).send(pct_color(percent_used))}" \
61
+ "#{("=" * (total_ticks - used_ticks)).light_black}]"
62
+ result << "\n" unless filesystem == @results.last
63
+ result
91
64
  end
92
65
 
93
- def parse_filesystem_usage(filesystems)
94
- command_result = `BLOCKSIZE=1024 df`.split("\n")
95
- header = command_result[0].split
96
- entries = command_result[1..command_result.count]
97
-
98
- name_index = find_header_id_by_text(header, 'filesystem')
99
- size_index = find_header_id_by_text(header, 'blocks')
100
- used_index = find_header_id_by_text(header, 'used')
101
- avail_index = find_header_id_by_text(header, 'avail')
102
-
103
- results = filesystems.map do |filesystem, name|
104
- matching_entry = entries.find { |e| e.split[name_index] == filesystem }
105
-
106
- if matching_entry
107
- {
108
- pretty_name: name,
109
- filesystem_name: matching_entry.split[name_index],
110
- size: matching_entry.split[size_index].to_i * 1024,
111
- used: matching_entry.split[used_index].to_i * 1024,
112
- avail: matching_entry.split[avail_index].to_i * 1024
113
- }
114
- else
115
- "#{filesystem} was not found"
116
- end
66
+ def pct_color(percentage)
67
+ case percentage
68
+ when 0..75 then :green
69
+ when 76..95 then :yellow
70
+ when 96..100 then :red
71
+ else :white
117
72
  end
73
+ end
118
74
 
119
- return results
75
+ def calc_percent_used(filesystem)
76
+ ((filesystem[:used].to_f / filesystem[:size].to_f) * 100).round
77
+ end
78
+
79
+ def format_percent_used(percent_used)
80
+ (percent_used.to_s.rjust(3, " ") + "%").send(pct_color(percent_used))
81
+ end
82
+
83
+ def calc_metric(value)
84
+ Unit.new("#{value} bytes").convert_to(calc_units(value))
85
+ end
86
+
87
+ def format_metric(filesystem, metric)
88
+ # we have 4 characters of space to include the number, a potential
89
+ # decimal point, and the unit character at the end. if the whole number
90
+ # component is 3+ digits long then we omit the decimal point and just
91
+ # display the whole number component. if the whole number component is
92
+ # 2 digits long, we can't afford to use a decimal point, so we still
93
+ # only display the whole number component. if the whole number
94
+ # component is 1 digit long, we use the single whole number component
95
+ # digit, a decimal point, a single fractional digit, and the unit
96
+ # character.
97
+
98
+ value = calc_metric(filesystem[metric])
99
+ whole_number_length = value.scalar.floor.to_s.length
100
+ round_amount = whole_number_length > 1 ? 0 : 1
101
+ formatted = value.scalar.round(round_amount).to_s + value.units[0].upcase
102
+
103
+ formatted.rjust(4, " ") + " "
104
+ end
105
+
106
+ def calc_units(value)
107
+ if value > 10 ** 12
108
+ "terabytes"
109
+ elsif value > 10 ** 9
110
+ "gigabytes"
111
+ elsif value > 10 ** 6
112
+ "megabytes"
113
+ elsif value > 10 ** 3
114
+ "kilobytes"
115
+ else
116
+ "bytes"
117
+ end
118
+ end
119
+
120
+ def parse_filesystem_usage(filesystems)
121
+ entries = `BLOCKSIZE=1024 df --output=source,size,used,avail`.lines.drop(1)
122
+
123
+ filesystems.map do |filesystem, pretty_name|
124
+ matching_entry = entries.map(&:split).find { |e| e.first == filesystem }
125
+ next "#{filesystem} was not found" unless matching_entry
126
+
127
+ filesystem_name, size, used, avail = matching_entry
128
+ {
129
+ pretty_name: pretty_name,
130
+ filesystem_name: filesystem_name,
131
+ size: size.to_i * 1024,
132
+ used: used.to_i * 1024,
133
+ avail: avail.to_i * 1024,
134
+ }
135
+ end
120
136
  end
121
137
  end
@@ -1,70 +1,104 @@
1
- require 'date'
1
+ # frozen_string_literal: true
2
2
 
3
- class LastLogin
4
- attr_reader :name, :errors, :results
3
+ require "date"
5
4
 
5
+ class LastLogin < Component
6
6
  def initialize(motd)
7
- @name = 'last_login'
8
- @motd = motd
9
- @config = motd.config.component_config(@name)
10
- @errors = []
7
+ super(motd, "last_login")
11
8
  end
12
9
 
10
+ # @see Component#process
13
11
  def process
14
- @users = @config['users']
15
- @results = parse_last_logins(@users)
12
+ @users = @config["users"]
13
+ @results = parse_last_logins
16
14
  end
17
15
 
16
+ # @see Component#to_s
18
17
  def to_s
19
- result = "Last Login:\n"
20
-
21
- @results.each do |user, logins|
22
- result += " #{user}:\n"
23
- location_string_size = logins.map { |l| l[:location].length }.max
24
- logins.each do |login|
25
- location_part = login[:location].ljust(location_string_size, ' ')
26
- start_part = login[:time_start].strftime('%m/%d/%Y %I:%M%p')
27
-
28
- end_part = if login[:time_end].is_a? String # still logged in text
29
- login[:time_end].green
30
- else
31
- "#{((login[:time_end] - login[:time_start]) * 24 * 60).to_i} minutes"
32
- end
18
+ <<~HEREDOC
19
+ Last Login:
20
+ #{@results.map { |u, l| parse_result(u, l) }.join("\n")}
21
+ HEREDOC
22
+ end
33
23
 
34
- result += " from #{location_part} at #{start_part} (#{end_part})\n"
35
- end
36
- result += ' no logins found for user.' if logins.empty?
37
- end
24
+ private
38
25
 
39
- return result
26
+ # Takes the list of processed results and generates a complete printable
27
+ # component string.
28
+ #
29
+ # @param user [String] the username to generate a string for
30
+ # @param logins [Array<Hash>] an array of hashes with username keys and hash
31
+ # values containing the login data (see {#hashify_login})
32
+ #
33
+ # @return [String] the printable string for a single user
34
+ def parse_result(user, logins)
35
+ logins_part = if logins.empty?
36
+ " no logins found for user."
37
+ else
38
+ longest_size = logins.map { |l| l[:location].length }.max
39
+ logins.map { |l| parse_login(l, longest_size) }.join("\n")
40
+ end
41
+ <<~HEREDOC
42
+ #{user}:
43
+ #{logins_part}
44
+ HEREDOC
40
45
  end
41
46
 
42
- private
47
+ # Takes login data and converts it to a heplful printable string.
48
+ #
49
+ # @param login [Hash] the login data, see {#hashify_login}
50
+ # @param longest_size [Integer] the longest string length to help with string
51
+ # formatting
52
+ #
53
+ # @return [String] the formatted string for printing
54
+ def parse_login(login, longest_size)
55
+ location = login[:location].ljust(longest_size, " ")
56
+ start = login[:time_start].strftime("%m/%d/%Y %I:%M%p")
57
+ finish = if login[:time_end].is_a? String # not a date
58
+ if login[:time_end] == "still logged in"
59
+ login[:time_end].green
60
+ else
61
+ login[:time_end].yellow
62
+ end
63
+ else
64
+ "#{((login[:time_end] - login[:time_start]) / 60).to_i} minutes"
65
+ end
66
+ " from #{location} at #{start} (#{finish})"
67
+ end
43
68
 
44
- def parse_last_logins(users)
45
- all_logins = {}
46
- users.each_with_index do |(username, num_logins), i|
47
- user_logins = []
69
+ # Takes a list of configured usernames and grabs login data from the system.
70
+ #
71
+ # @return [Hash{Symbol => Hash}] a hash with username keys and hash values
72
+ # containing the login data (see {#hashify_login})
73
+ def parse_last_logins
74
+ @users.map do |(username, num_logins)|
48
75
  cmd_result = `last --time-format=iso #{username}`
49
- cmd_result.split("\n").each do |entry|
50
- next unless entry.start_with? username
51
- data = entry.split(/(?:\s{2,})|(?:\s-\s)/)
76
+ logins = cmd_result.lines
77
+ .select { |entry| entry.start_with?(username) }
78
+ .take(num_logins)
52
79
 
53
- time_end = data[4] == 'still logged in' ? data[4] : DateTime.parse(data[4])
54
-
55
- user_logins << {
56
- username: username,
57
- location: data[2],
58
- time_start: DateTime.parse(data[3]),
59
- time_end: time_end
60
- }
80
+ [username.to_sym, logins.map! { |l| hashify_login(l, username) }]
81
+ end.to_h
82
+ end
61
83
 
62
- break if user_logins.count >= num_logins
63
- end
84
+ # A hash representation of a single login.
85
+ #
86
+ # @param login [String] the raw string result from the call to the system
87
+ # containing various bits of login information
88
+ # @param username [String] the username of the logged user
89
+ #
90
+ # @return [Hash] the parsed login entry data, with symbol keys
91
+ def hashify_login(login, username)
92
+ re = login.chomp.split(/(?:\s{2,})|(?<=\d)(?:\s-\s)/)
93
+ date = re[4].scan(/\d{4}-\d{2}-[\dT:]+-\d{4}/)
64
94
 
65
- all_logins[username.to_sym] = user_logins
66
- end
95
+ time_end = date.any? ? Time.parse(re[4]) : re[4]
67
96
 
68
- return all_logins
97
+ {
98
+ username: username, # string username
99
+ location: re[2], # string login location, an IP address
100
+ time_start: Time.parse(re[3]),
101
+ time_end: time_end, # Time or string, could be "still logged in"
102
+ }
69
103
  end
70
104
  end