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