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 +5 -5
- data/bin/panda-motd +1 -1
- data/lib/panda_motd.rb +13 -4
- data/lib/panda_motd/component.rb +35 -0
- data/lib/panda_motd/component_error.rb +4 -1
- data/lib/panda_motd/components/ascii_text_art.rb +12 -18
- data/lib/panda_motd/components/fail_2_ban.rb +46 -0
- data/lib/panda_motd/components/filesystems.rb +110 -94
- data/lib/panda_motd/components/last_login.rb +83 -49
- data/lib/panda_motd/components/service_status.rb +53 -45
- data/lib/panda_motd/components/ssl_certificates.rb +110 -47
- data/lib/panda_motd/components/uptime.rb +31 -14
- data/lib/panda_motd/config.rb +36 -14
- data/lib/panda_motd/default_config.yaml +24 -2
- data/lib/panda_motd/motd.rb +18 -5
- data/lib/panda_motd/version.rb +3 -1
- metadata +110 -38
@@ -1,68 +1,76 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
attr_reader :name, :errors
|
5
|
-
attr_reader :results
|
3
|
+
require "colorize"
|
6
4
|
|
5
|
+
class ServiceStatus < Component
|
7
6
|
def initialize(motd)
|
8
|
-
|
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[
|
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
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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,
|
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
|
-
|
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
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
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
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
attr_reader :name, :errors, :results
|
3
|
+
require "date"
|
5
4
|
|
5
|
+
class SSLCertificates < Component
|
6
6
|
def initialize(motd)
|
7
|
-
|
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[
|
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
|
-
|
20
|
-
|
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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
-
|
31
|
-
result += name_portion + date_portion
|
32
|
-
end
|
30
|
+
private
|
33
31
|
|
34
|
-
|
35
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
76
|
+
certs.map do |name, path|
|
44
77
|
if File.exist?(path)
|
45
|
-
|
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
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
-
|
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
|
-
|
80
|
-
valid:
|
81
|
-
expiring:
|
82
|
-
expired:
|
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
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
3
|
+
require "sysinfo"
|
4
|
+
|
5
|
+
class Uptime < Component
|
5
6
|
attr_reader :days, :hours, :minutes
|
6
7
|
|
7
8
|
def initialize(motd)
|
8
|
-
|
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
|
-
|
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
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
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
|
data/lib/panda_motd/config.rb
CHANGED
@@ -1,9 +1,15 @@
|
|
1
|
-
|
2
|
-
|
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
|
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
|
-
#
|
16
|
-
|
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
|
-
|
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
|
-
|
33
|
+
@config["components"][component_name.to_s]
|
23
34
|
end
|
24
35
|
|
25
|
-
|
26
|
-
|
27
|
-
|
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(
|
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[
|
67
|
+
@config["components"] = [] if @config["components"].nil?
|
46
68
|
end
|
47
69
|
end
|