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.
@@ -1,68 +1,76 @@
1
- require 'colorize'
1
+ # frozen_string_literal: true
2
2
 
3
- class ServiceStatus
4
- attr_reader :name, :errors
5
- attr_reader :results
3
+ require "colorize"
6
4
 
5
+ class ServiceStatus < Component
7
6
  def initialize(motd)
8
- @name = 'service_status'
9
- @motd = motd
10
- @config = motd.config.component_config(@name)
11
- @errors = []
7
+ super(motd, "service_status")
12
8
  end
13
9
 
10
+ # @see Component#process
14
11
  def process
15
- @services = @config['services']
12
+ @services = @config["services"]
16
13
  @results = parse_services(@services)
17
14
  end
18
15
 
16
+ # Gets a printable string to be printed in the MOTD. If there are no services
17
+ # found in the result, it prints a warning message.
19
18
  def to_s
20
- if @results.any?
21
- result = "Services:\n"
22
- longest_name_size = @results.keys.map { |k| k.to_s.length }.max + 1 # add 1 for the ':' at the end
23
- @results.each_with_index do |(name, status), i|
24
- name_part = (name.to_s + ':').ljust(longest_name_size, ' ')
25
- status_part = status.to_s.send(service_colors[status])
26
- result += " #{name_part} #{status_part}"
27
- result += "\n" unless i == @results.count - 1 # don't print newline for last entry
28
- end
19
+ return "Services:\n No matching services found." unless @results.any?
29
20
 
30
- return result
31
- else
32
- return "Services:\n No matching services found."
33
- end
21
+ longest_name_size = @results.keys.map { |k| k.to_s.length }.max
22
+ result = <<~HEREDOC
23
+ Services:
24
+ #{@results.map do |(name, status)|
25
+ spaces = (" " * (longest_name_size - name.to_s.length + 1))
26
+ status_part = status.to_s.colorize(service_colors[status.to_sym])
27
+ " #{name}#{spaces}#{status_part}"
28
+ end.join("\n")}
29
+ HEREDOC
30
+
31
+ result.gsub(/\s$/, "")
34
32
  end
35
33
 
36
34
  private
37
35
 
38
- def parse_services(services)
39
- results = {}
40
-
41
- cmd_result = `systemctl | grep '\.service'`
42
-
36
+ # Runs a `systemd` command to determine the state of a service. If the state
37
+ # of the service was unable to be determined, an error will be added to the
38
+ # component.
39
+ #
40
+ # @param service [String] the name of the systemd service
41
+ #
42
+ # @return [String] the state of the systemd service
43
+ def parse_service(service)
44
+ cmd_result = `systemd is-active #{service[0]}`.strip
43
45
  if cmd_result.empty?
44
- @errors << ComponentError.new(self, 'Unable to parse systemctl output')
45
- end
46
-
47
- cmd_result.split("\n").each do |line|
48
- parsed_name = line.split[0].gsub('.service', '')
49
- parsed_status = line.split[3]
50
-
51
- matching_service = services.find { |service, _name| service == parsed_name }
52
-
53
- if matching_service
54
- results[parsed_name.to_sym] = parsed_status.to_sym
55
- end
46
+ @errors << ComponentError.new(self, "systemctl output was blank.")
56
47
  end
48
+ cmd_result
49
+ end
57
50
 
58
- return results
51
+ # Takes a list of services from a configuration file, and turns them into a
52
+ # hash with the service states as values.
53
+ #
54
+ # @param services [Array] a two-element array where the first element is the
55
+ # name of the systemd service, and the second is the pretty name that
56
+ # represents it.
57
+ #
58
+ # @return [Hash]
59
+ # * `key`: The symbolized name of the systemd service
60
+ # * `value`: The symbolized service state
61
+ def parse_services(services)
62
+ services.map { |s| [s[1].to_sym, parse_service(s).to_sym] }.to_h
59
63
  end
60
64
 
65
+ # A hash of mappings between a service state and a color which represents it.
66
+ # The hash has a default value of red in order to handle unexpected service
67
+ # status strings returned by `systemctl`.
61
68
  def service_colors
62
- return {
63
- running: :green,
64
- exited: :white,
65
- failed: :red
66
- }
69
+ colors = Hash.new(:red)
70
+ colors[:active] = :green
71
+ colors[:inactive] = :yellow
72
+ colors[:failed] = :red
73
+
74
+ colors
67
75
  end
68
76
  end
@@ -1,85 +1,148 @@
1
- require 'date'
1
+ # frozen_string_literal: true
2
2
 
3
- class SSLCertificates
4
- attr_reader :name, :errors, :results
3
+ require "date"
5
4
 
5
+ class SSLCertificates < Component
6
6
  def initialize(motd)
7
- @name = 'ssl_certificates'
8
- @motd = motd
9
- @config = motd.config.component_config(@name)
10
- @errors = []
7
+ super(motd, "ssl_certificates")
11
8
  end
12
9
 
10
+ # @see Component#process
13
11
  def process
14
- @certs = @config['certs']
12
+ @certs = @config["certs"]
15
13
  @results = cert_dates(@certs)
16
14
  end
17
15
 
16
+ # Prints the list of SSL certificates with their statuses. If a certificate
17
+ # is not found at the configured location, a message will be printed which
18
+ # explains this.
18
19
  def to_s
19
- result = "SSL Certificates:\n"
20
- longest_name_size = @results.map { |r| r[0].length }.max
20
+ <<~HEREDOC
21
+ SSL Certificates:
22
+ #{sorted_results.map do |cert|
23
+ return " #{cert}" if cert.is_a? String # print the not found message
21
24
 
22
- @results.each_with_index do |cert, i|
23
- if cert.is_a? String # print the not found message
24
- result += " #{cert}"
25
- else
26
- name_portion = " #{cert[0]}".ljust(longest_name_size + 6, ' ')
27
-
28
- status = cert_status(cert[1])
25
+ parse_cert(cert)
26
+ end.join("\n")}
27
+ HEREDOC
28
+ end
29
29
 
30
- date_portion = "#{cert_status_strings[status]} ".send(cert_status_colors[status]) + cert[1].strftime('%e %b %Y %H:%M:%S%p').to_s
31
- result += name_portion + date_portion
32
- end
30
+ private
33
31
 
34
- result += "\n" unless i == @results.count - 1 # don't print newline for last entry
35
- end
32
+ # Takes an entry from `@results` and formats it in a way that is conducive
33
+ # to being printed in the context of the MOTD.
34
+ #
35
+ # @param cert [Array] a two-element array in the same format as the return
36
+ # value of {#parse_result}
37
+ def parse_cert(cert)
38
+ name_portion = cert[0].ljust(longest_cert_name_length + 6, " ")
39
+ status_sym = cert_status(cert[1])
40
+ status = cert_status_strings[status_sym].to_s
41
+ colorized_status = status.colorize(cert_status_colors[status_sym])
42
+ date_portion = cert[1].strftime("%e %b %Y %H:%M:%S%p")
43
+ " #{name_portion} #{colorized_status} #{date_portion}"
44
+ end
36
45
 
37
- return result
46
+ # Determines the length of the longest SSL certificate name for use in
47
+ # formatting the output of the component.
48
+ #
49
+ # @return [Integer] the length of the longest certificate name
50
+ def longest_cert_name_length
51
+ @results.map { |r| r[0].length }.max
38
52
  end
39
53
 
40
- private
54
+ # Takes the results array and sorts it according to the configured sort
55
+ # method. If the option is not set or is set improperly, it will default to
56
+ # alphabetical.
57
+ def sorted_results
58
+ if @config["sort_method"] == "alphabetical"
59
+ @results.sort_by { |c| c[0] }
60
+ elsif @config["sort_method"] == "expiration"
61
+ @results.sort_by { |c| c[1] }
62
+ else # default to alphabetical
63
+ @results.sort_by { |c| c[0] }
64
+ end
65
+ end
41
66
 
67
+ # Takes a list of certificates and compiles a list of results for each
68
+ # certificate. If a certificate was not found, a notice will be returned
69
+ # instead.
70
+ #
71
+ # @return [Array] An array of parsed results. If there was an error, the
72
+ # element will be just a string. If it was successful, the element will be
73
+ # another two-element array in the same format as the return value of
74
+ # {#parse_result}.
42
75
  def cert_dates(certs)
43
- return certs.map do |name, path|
76
+ certs.map do |name, path|
44
77
  if File.exist?(path)
45
- cmd_result = `openssl x509 -in #{path} -dates`
46
- parsed = cmd_result.match(/notAfter=([\w\s:]+)\n/)
47
- if parsed.nil?
48
- @errors << ComponentError.new(self, 'Unable to find certificate expiration date')
49
- else
50
- begin
51
- expiry_date = DateTime.parse(parsed[1])
52
- [name, expiry_date]
53
- rescue ArgumentError
54
- @errors << ComponentError.new(self, 'Found expiration date, but unable to parse as date')
55
- end
56
- end
78
+ parse_result(name, path)
57
79
  else
58
80
  "Certificate #{name} not found at path: #{path}"
59
81
  end
82
+ end.compact # remove nil entries, will have nil if error ocurred
83
+ end
84
+
85
+ # Uses `openssl` to obtain and parse and expiration date for the certificate.
86
+ #
87
+ # @param name [String] the name of the SSL certificate
88
+ # @param path [String] the file path to the SSL certificate
89
+ #
90
+ # @return [Array] A pair where the first element is the configured name of
91
+ # the SSL certificate, and the second element is the expiration date of
92
+ # the certificate.
93
+ def parse_result(name, path)
94
+ cmd_result = `openssl x509 -in #{path} -dates`
95
+ # match indices: 1 - month, 2 - day, 3 - time, 4 - year, 5 - zone
96
+ exp = /notAfter=([A-Za-z]+) +(\d+) +([\d:]+) +(\d{4}) +([A-Za-z]+)\n/
97
+ parsed = cmd_result.match(exp)
98
+
99
+ if parsed.nil?
100
+ @errors << ComponentError.new(self, "Unable to find certificate " \
101
+ "expiration date")
102
+ nil
103
+ else
104
+ expiry_date = Time.parse([1, 2, 4, 3, 5].map { |n| parsed[n] }.join(" "))
105
+ [name, expiry_date]
60
106
  end
107
+ rescue ArgumentError
108
+ @errors << ComponentError.new(self, "Found expiration date, but unable " \
109
+ "to parse as date")
110
+ [name, Time.now]
61
111
  end
62
112
 
113
+ # Maps an expiration date to a symbol representing the expiration status of
114
+ # an SSL certificate.
115
+ #
116
+ # @param expiry_date [Time] the time at which the certificate expires
117
+ #
118
+ # @return [Symbol] A symbol representing the expiration status of the
119
+ # certificate. Valid values are `:expiring`, `:expired`, and `:valid`.
63
120
  def cert_status(expiry_date)
64
- status = :valid
65
- status = :expiring if (DateTime.now...DateTime.now + 30).cover? expiry_date # ... range excludes end
66
- status = :expired if DateTime.now >= expiry_date
67
- return status
121
+ if (Time.now...Time.now + 30).cover? expiry_date # ... range excludes last
122
+ :expiring
123
+ elsif Time.now >= expiry_date
124
+ :expired
125
+ else
126
+ :valid
127
+ end
68
128
  end
69
129
 
130
+ # Maps a certificate expiration status to a color that represents it.
70
131
  def cert_status_colors
71
- return {
132
+ {
72
133
  valid: :green,
73
134
  expiring: :yellow,
74
- expired: :red
135
+ expired: :red,
75
136
  }
76
137
  end
77
138
 
139
+ # Maps a certificate expiration status to a string which can be prefixed to
140
+ # the expiration date, to aid in explaining when the certificate expires.
78
141
  def cert_status_strings
79
- return {
80
- valid: 'valid until',
81
- expiring: 'expiring at',
82
- expired: 'expired at'
142
+ {
143
+ valid: "valid until",
144
+ expiring: "expiring at",
145
+ expired: "expired at",
83
146
  }
84
147
  end
85
148
  end
@@ -1,30 +1,47 @@
1
- require 'sysinfo'
1
+ # frozen_string_literal: true
2
2
 
3
- class Uptime
4
- attr_reader :name, :errors
3
+ require "sysinfo"
4
+
5
+ class Uptime < Component
5
6
  attr_reader :days, :hours, :minutes
6
7
 
7
8
  def initialize(motd)
8
- @name = 'uptime'
9
- @motd = motd
10
- @config = motd.config.component_config(@name)
11
- @errors = []
9
+ super(motd, "uptime")
12
10
  end
13
11
 
12
+ # Calculates the number of days, hours, and minutes based on the current
13
+ # uptime value.
14
+ #
15
+ # @see Component#process
14
16
  def process
15
- sysinfo = SysInfo.new
16
- uptime = sysinfo.uptime
17
+ uptime = SysInfo.new.uptime
17
18
 
18
19
  @days = (uptime / 24).floor
19
20
  @hours = (uptime - @days * 24).floor
20
21
  @minutes = ((uptime - @days * 24 - hours) * 60).floor
21
22
  end
22
23
 
24
+ # Gets a printable uptime string with a prefix. The prefix can be configured,
25
+ # and defaults to "up".
23
26
  def to_s
24
- result = ''
25
- result += "#{@days} day#{'s' if @days != 1}, " unless @days.zero?
26
- result += "#{@hours} hour#{'s' if @hours != 1}, " unless @hours.zero? && @days.zero?
27
- result += "#{@minutes} minute#{'s' if @minutes != 1}"
28
- return "#{@config['prefix'] || 'up'} #{result}"
27
+ "#{@config["prefix"] || "up"} #{format_uptime}"
28
+ end
29
+
30
+ private
31
+
32
+ # Formats the uptime values in such a way that it is easier to read. If any
33
+ # of the measurements are zero, that part is omitted. Words are properly
34
+ # pluralized.
35
+ #
36
+ # Examples:
37
+ #
38
+ # `3d 20h 55m` becomes `3 days, 20 hours, 55 minutes`
39
+ #
40
+ # `3d 0h 55m` becomes `3 days, 55 minutes`
41
+ def format_uptime
42
+ [@days, @hours, @minutes].zip(%w[day hour minute])
43
+ .reject { |n, _word| n.zero? }
44
+ .map { |n, word| "#{n} #{word}#{"s" if n != 1}" }
45
+ .join(", ")
29
46
  end
30
47
  end
@@ -1,9 +1,15 @@
1
- require 'yaml'
2
- require 'fileutils'
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
3
5
 
4
6
  class Config
7
+ attr_reader :file_path
8
+
9
+ # @param file_path [String] The file path to look for the configuration file.
10
+ # If not provided, the default file path will be used.
5
11
  def initialize(file_path = nil)
6
- @file_path = file_path.nil? ? File.join(Dir.home, '.config', 'panda-motd.yaml') : file_path
12
+ @file_path = file_path || File.join(Dir.home, ".config", "panda-motd.yaml")
7
13
  unless File.exist?(@file_path)
8
14
  create_config(@file_path)
9
15
  puts "panda-motd created a default config file at: #{@file_path}"
@@ -11,37 +17,53 @@ class Config
11
17
  load_config(@file_path)
12
18
  end
13
19
 
20
+ # A list of enabled components' class constants.
14
21
  def components_enabled
15
- # first iterate through the config hash and grab all names of enabled components
16
- enabled_list = @config['components'].map { |component, setting| component if setting['enabled'] }.compact
22
+ # iterate config hash and grab names of enabled components
23
+ enabled = @config["components"].map { |c, s| c if s["enabled"] }.compact
17
24
  # get the class constant
18
- return enabled_list.map { |e| component_classes[e.to_sym] }
25
+ enabled.map { |e| Config.component_classes[e.to_sym] }
19
26
  end
20
27
 
28
+ # Gets the configuration for a component.
29
+ #
30
+ # @param component_name [String] the name of the component to fetch the
31
+ # configuration for
21
32
  def component_config(component_name)
22
- return @config['components'][component_name.to_s]
33
+ @config["components"][component_name.to_s]
23
34
  end
24
35
 
25
- private
26
-
27
- def component_classes
28
- return {
36
+ # The mapping of component string names to class constants.
37
+ def self.component_classes
38
+ {
29
39
  ascii_text_art: ASCIITextArt,
30
40
  service_status: ServiceStatus,
31
41
  uptime: Uptime,
32
42
  ssl_certificates: SSLCertificates,
33
43
  filesystems: Filesystems,
34
- last_login: LastLogin
44
+ last_login: LastLogin,
45
+ fail_2_ban: Fail2Ban,
35
46
  }
36
47
  end
37
48
 
49
+ private
50
+
51
+ # Creates a configuration file at a given file path, from the default
52
+ # configuration file.
53
+ #
54
+ # @param file_path [String] the file path at which to create the config
38
55
  def create_config(file_path)
39
- default_config_path = File.join(File.dirname(__dir__), 'panda_motd', 'default_config.yaml')
56
+ default_config_path = File.join(
57
+ File.dirname(__dir__), "panda_motd", "default_config.yaml"
58
+ )
40
59
  FileUtils.cp(default_config_path, file_path)
41
60
  end
42
61
 
62
+ # Loads a configuration file.
63
+ #
64
+ # @param file_path [String] the file path of the config to load
43
65
  def load_config(file_path)
44
66
  @config = YAML.safe_load(File.read(file_path))
45
- @config['components'] = [] if @config['components'].nil?
67
+ @config["components"] = [] if @config["components"].nil?
46
68
  end
47
69
  end