Skip to content

Commit

Permalink
Add check and notification on TLS/SSL certificate expiration #152
Browse files Browse the repository at this point in the history
  • Loading branch information
brotandgames committed Jun 13, 2023
1 parent 136274e commit 612ea4a
Show file tree
Hide file tree
Showing 19 changed files with 166 additions and 18 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Open http://localhost:8090 in your webbrowser.
* Check HTTP/S endpoints in an interval
* Use Cron syntax like `* * * * *` (every minute), `*/15 * * * *` (every 15 minutes), `@hourly` or `@daily` etc.
* Keep track of status changes (since version 1.8.0)
* Check TLS certificate expiration once a day and get a notification if it expires in less than 30 days (since version 1.9.0)
* Web UI
* [RESTful JSON API](#rest-api)
* Get a notification on status change via [E-Mail](smtp_configuration.md) eg. Gmail, Sendgrid, MailChimp etc. (optional)
Expand Down Expand Up @@ -147,7 +148,7 @@ export SECRET_KEY_BASE="sensitive_secret_key_base" \
SMTP_ENABLE_STARTTLS_AUTO=true \
SMTP_USERNAME=ciao \
SMTP_PASSWORD="sensitive_password"

# Precompile assets
rails assets:precompile

Expand Down
15 changes: 15 additions & 0 deletions app/helpers/checks_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ def class_for_status(status)
end
end

# Converts the tls_expires_in_days to the corresponding CSS class
# to control HTML element classes based on tls_expires_in_days
# @param tls_expires_in_days [Integer] TLS certificate expiration in days
# @return [String] the CSS class for the corresponding tls_expires_in_days
def class_for_tls_expires_in_days(tls_expires_in_days)
case tls_expires_in_days
when 0..7
'text-danger'
when 8..30
'text-warning'
when 31..Float::INFINITY
'text'
end
end

# Converts the healthcheck's active flag to CSS class
# to control HTML element color
# @param active [Boolean] the healthcheck's active flag, `true` or `false`
Expand Down
6 changes: 6 additions & 0 deletions app/lib/ciao/notifications/mail_notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,11 @@ def notify(payload_data = {})
CheckMailer.with(payload_data).change_status_mail.deliver
end
end

class MailNotificationTlsExpires < Base
def notify(payload_data = {})
CheckMailer.with(payload_data).tls_expires_mail.deliver
end
end
end
end
5 changes: 4 additions & 1 deletion app/lib/ciao/parsers/webhook_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ module Parsers
class WebhookParser
WEBHOOKS_ENDPOINT_PREFIX = 'CIAO_WEBHOOK_ENDPOINT_'
WEBHOOKS_PAYLOAD_PREFIX = 'CIAO_WEBHOOK_PAYLOAD_'
WEBHOOKS_PAYLOAD_TLS_EXPIRES_PREFIX = 'CIAO_WEBHOOK_PAYLOAD_TLS_EXPIRES_'

WEBHOOKS_ENDPOINT_FORMAT = "#{WEBHOOKS_ENDPOINT_PREFIX}%s".freeze
WEBHOOKS_PAYLOAD_FORMAT = "#{WEBHOOKS_PAYLOAD_PREFIX}%s".freeze
WEBHOOKS_PAYLOAD_TLS_EXPIRES_FORMAT = "#{WEBHOOKS_PAYLOAD_TLS_EXPIRES_PREFIX}%s".freeze

WEBHOOKS_FORMAT_REGEX = /^#{WEBHOOKS_ENDPOINT_PREFIX}(?<name>[A-Z0-9_]+)$/

def self.webhooks
names.map do |check_name|
{
endpoint: ENV.fetch(WEBHOOKS_ENDPOINT_FORMAT % check_name, ''),
payload: ENV.fetch(WEBHOOKS_PAYLOAD_FORMAT % check_name, '')
payload: ENV.fetch(WEBHOOKS_PAYLOAD_FORMAT % check_name, ''),
payload_tls_expires: ENV.fetch(WEBHOOKS_PAYLOAD_TLS_EXPIRES_FORMAT % check_name, '')
}
end
end
Expand Down
4 changes: 4 additions & 0 deletions app/lib/ciao/renderers/replace_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ class ReplaceRenderer < Base
STATUS_BEFORE_PLACEHOLDER = '__status_before__'
URL_PLACEHOLDER = '__url__'
CHECK_URL_PLACEHOLDER = '__check_url__'
TLS_EXPIRES_AT_PLACEHOLDER = '__tls_expires_at__'
TLS_EXPIRES_IN_DAYS_PLACEHOLDER = '__tls_expires_in_days__'

def render(data)
return '' if @template.nil?
Expand All @@ -18,6 +20,8 @@ def render(data)
.gsub(STATUS_BEFORE_PLACEHOLDER, data.fetch(:status_before, '').to_s)
.gsub(URL_PLACEHOLDER, data.fetch(:url, '').to_s)
.gsub(CHECK_URL_PLACEHOLDER, data.fetch(:check_url, '').to_s)
.gsub(TLS_EXPIRES_AT_PLACEHOLDER, data.fetch(:tls_expires_at, '').to_s)
.gsub(TLS_EXPIRES_IN_DAYS_PLACEHOLDER, data.fetch(:tls_expires_in_days, '').to_s)
end
end
end
Expand Down
12 changes: 12 additions & 0 deletions app/mailers/check_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,16 @@ def change_status_mail
@status_after = params[:status_after]
mail(subject: "[ciao] #{@name}: Status changed (#{@status_after})")
end

# Sends mail to inform the receiver about a
# expiration of TLS certificate
# @param name [String] the name of the healthcheck
# @param tls_expires_at [DateTime] DateTime when the TLS certificate expires
# @param tls_expires_in_days [Integer] Days until the TLS certificate expires
def tls_expires_mail
@name = params[:name]
@tls_expires_at = params[:tls_expires_at]
@tls_expires_in_days = params[:tls_expires_in_days]
mail(subject: "[ciao] #{@name}: TLS certificate expires in (#{@tls_expires_in_days})")
end
end
33 changes: 32 additions & 1 deletion app/models/check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# @attr [string] job rufus-scheduler's last run job ID
# @attr [datetime] last_contact_at when the healthcheck was last run
# @attr [datetime] next_contact_at when the healthcheck will next run
class Check < ApplicationRecord
class Check < ApplicationRecord # rubocop:disable Metrics/ClassLength
has_many :status_changes, dependent: :destroy

after_create :create_job, if: :active?
Expand Down Expand Up @@ -104,6 +104,37 @@ def unschedule_job
end
end

def create_tls_job
uri = URI.parse(url)
return unless uri.scheme == 'https'

Rufus::Scheduler.singleton.cron '0 12 * * *', job: true do
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
tls_expires_at = nil
http.start do |h|
tls_expires_at = h.peer_cert.not_after
end
tls_expires_in_days = (tls_expires_at - Time.now).to_i / (24 * 60 * 60)
ActiveRecord::Base.connection_pool.with_connection do
update_columns(tls_expires_at: tls_expires_at, tls_expires_in_days: tls_expires_in_days)
end

if tls_expires_in_days < 30
NOTIFICATIONS_TLS_EXPIRES.each do |notification|
notification.notify(
name: name,
url: url,
check_url: Rails.application.routes.url_helpers.check_path(self),
tls_expires_at: tls_expires_at,
tls_expires_in_days: tls_expires_in_days
)
end
end
end
end

private

def update_routine
Expand Down
3 changes: 3 additions & 0 deletions app/views/check_mailer/tls_expires_mail.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ciao

The TLS certificate of '<%= @name %>' expires at <%= @tls_expires_at %> (in <%= @tls_expires_in_days %> days)
3 changes: 2 additions & 1 deletion app/views/checks/_check.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

json.extract! check, :id, :active, :name, :cron, :url, :status, :job,
:last_contact_at, :next_contact_at, :created_at, :updated_at
:last_contact_at, :next_contact_at, :created_at, :updated_at,
:tls_expires_at, :tls_expires_in_days
json.check_url check_url(check, format: :json)
12 changes: 10 additions & 2 deletions app/views/checks/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,16 @@
<%= truncate(check.status, :length => 37) %>
</div>
<% end %>
<div class="small text-muted">
<%= truncate(check.job, :length => 15, omission: '') %>
<div class="small <%= class_for_tls_expires_in_days(check.tls_expires_in_days) %>">
<% if URI.parse(check.url).scheme == 'https' %>
<% if check.tls_expires_at? %>
TLS expires in <%= check.tls_expires_in_days %> days
<% else %>
Waiting for TLS check
<% end %>
<% else %>
No TLS certificate
<% end %>
</div>
</td>
<td>
Expand Down
9 changes: 9 additions & 0 deletions app/views/checks/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@
<span><%= @check.last_contact_at %></span>
</p>

<% if URI.parse(@check.url).scheme == 'https' && @check.tls_expires_at? %>
<p>
<strong>TLS expires at:</strong>
<span class="<%= class_for_tls_expires_in_days(@check.tls_expires_in_days) %>">
<%= @check.tls_expires_at %> (in <%= @check.tls_expires_at %>)
</span>
</p>
<% end %>

<p>
<strong>Status changes: <%= @check.status_changes.count %></strong>
<ul>
Expand Down
4 changes: 3 additions & 1 deletion config/initializers/create_background_jobs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# Create all Rufus Scheduler Jobs for active checks on Application Start
# Prevent the initializer to be run during rake tasks

if defined?(Rails::Server) && ActiveRecord::Base.connection.table_exists?('checks') # rubocop:disable Style/IfUnlessModifier
if defined?(Rails::Server) && ActiveRecord::Base.connection.table_exists?('checks')
Check.active.each(&:create_job)
Check.active.each(&:create_tls_job)

end
10 changes: 10 additions & 0 deletions config/initializers/notifications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@
end

NOTIFICATIONS << Ciao::Notifications::MailNotification.new if ENV['SMTP_ADDRESS'].present?

NOTIFICATIONS_TLS_EXPIRES = Ciao::Parsers::WebhookParser.webhooks.map do |webhook|
Ciao::Notifications::WebhookNotification.new(
webhook[:endpoint],
webhook[:payload_tls_expires],
Ciao::Renderers::ReplaceRenderer
)
end

NOTIFICATIONS_TLS_EXPIRES << Ciao::Notifications::MailNotificationTlsExpires.new if ENV['SMTP_ADDRESS'].present?
8 changes: 8 additions & 0 deletions db/migrate/20230613104350_add_tls_expires_at_to_checks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class AddTlsExpiresAtToChecks < ActiveRecord::Migration[6.1]
def change
add_column :checks, :tls_expires_at, :datetime
add_index :checks, :tls_expires_at
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddTlsExpiresInDaysToChecks < ActiveRecord::Migration[6.1]
def change
add_column :checks, :tls_expires_in_days, :integer
end
end
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 20_221_114_215_533) do
ActiveRecord::Schema.define(version: 20_230_613_114_013) do
create_table 'checks', force: :cascade do |t|
t.string 'name'
t.string 'cron'
Expand All @@ -24,6 +24,9 @@
t.string 'job'
t.datetime 'last_contact_at'
t.datetime 'next_contact_at'
t.datetime 'tls_expires_at'
t.integer 'tls_expires_in_days'
t.index ['tls_expires_at'], name: 'index_checks_on_tls_expires_at'
end

create_table 'status_changes', force: :cascade do |t|
Expand Down
6 changes: 1 addition & 5 deletions scripts/chart/ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,10 @@ cd $(dirname $0)/..

rm -rf /tmp/chart
mkdir -p /tmp/chart/
cp -Rf ../chart /tmp/chart/$CHART_NAME
cp -Rf ../helm-chart/ciao /tmp/chart/$CHART_NAME

echo "== $self Chart validate"
helm lint /tmp/chart/$CHART_NAME

echo "== $self Helm init"
echo "== $self Check: https://github.com/helm/helm/issues/1732"
helm init --client-only

echo "== $self Chart package"
helm package -d /tmp/chart /tmp/chart/$CHART_NAME
4 changes: 3 additions & 1 deletion test/unit/ciao/parsers/webhook_parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ class WebhookParserTest < ActiveSupport::TestCase
test 'self.webhooks' do
ENV['CIAO_WEBHOOK_ENDPOINT_1'] = 'https://foo.bar'
ENV['CIAO_WEBHOOK_PAYLOAD_1'] = '{"foo":"bar"}'
ENV['CIAO_WEBHOOK_PAYLOAD_TLS_EXPIRES_1'] = '{"foo":"bar"}'
assert_equal [{
endpoint: 'https://foo.bar',
payload: '{"foo":"bar"}'
payload: '{"foo":"bar"}',
payload_tls_expires: '{"foo":"bar"}'
}], Ciao::Parsers::WebhookParser.webhooks
end
end
Expand Down
35 changes: 31 additions & 4 deletions webhook_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ You can configure as many webhooks as you like. Each webhook consists of 2 ENV v

like:

````
# Webhook for Rocketchat
```
# Webhook for Rocket.Chat
CIAO_WEBHOOK_ENDPOINT_ROCKETCHAT="https://webhook.rocketchat.com/***/***"
CIAO_WEBHOOK_PAYLOAD_ROCKETCHAT='{"text":"[ciao] __name__: Status changed (__status_after__)"}'
Expand All @@ -21,7 +21,7 @@ CIAO_WEBHOOK_ENDPOINT_SLACK="https://webhook.slack.com/***/***"
CIAO_WEBHOOK_PAYLOAD_SLACK='{"text":"[ciao] __name__: Status changed (__status_after__)"}'
etc.
````
```

There are 5 placeholders which you can use in the payload:

Expand All @@ -33,14 +33,41 @@ There are 5 placeholders which you can use in the payload:

ENV variable `CIAO_WEBHOOK_PAYLOAD_$NAME` has to be a valid JSON one-liner wrapped in single quotes like `'{"name":"__name__", "status_before":"__status_before__", "status_after":"__status_after__", "check_url":"__check_url__", "url":"__url__"}'`

> New as of 1.9.0
You can configure webhooks for TLS certificate expiration with one additional ENV variable:

* `CIAO_WEBHOOK_PAYLOAD_TLS_EXPIRES_$NAME`

like:

```
# Webhook payload for TLS certificate expiration for Rocket.Chat
CIAO_WEBHOOK_PAYLOAD_TLS_EXPIRES_ROCKETCHAT='{"text": "[ciao] TLS certificate for __name__ expires at __tls_expires_at__ (in __tls_expires_in_days__ days)"}'
# Webhook payload for TLS certificate expiration for Slack
CIAO_WEBHOOK_PAYLOAD_TLS_EXPIRES_SLACK='{"text": "[ciao] TLS certificate for __name__ expires at __tls_expires_at__ (in __tls_expires_in_days__ days)"}'
etc.
```

There are 5 placeholders which you can use in the payload for TLS certificate expiration:

* `__name__`
* `__url__`
* `__tls_expires_at__`
* `__tls_expires_in_days__`
* `__check_url__`

## Notes

* If you are using `docker-compose`, you have to omit the outer `""` and `''` in `*_ENDPOINT_*` and `*_PAYLOAD_*` - take a look at these GitHub issues ([1](https://github.com/brotandgames/ciao/issues/40), [2](https://github.com/docker/compose/issues/2854)) and these Stack Overflow questions ([1](https://stackoverflow.com/questions/53082932/yaml-docker-compose-spaces-quotes), [2](https://stackoverflow.com/questions/41988809/docker-compose-how-to-escape-environment-variables))
* You can add an Example configuration for a Service that's missing in the list via PR

## Example configurations

### RocketChat
### Rocket.Chat

````
# Endpoint
Expand Down

0 comments on commit 612ea4a

Please sign in to comment.