panda-motd 0.0.6 → 0.0.12

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.
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