Merge pull request #35 from Evolix/checks-ssl

Checks SSL
This commit is contained in:
Colin Darie 2018-07-03 10:22:09 +02:00 committed by GitHub
commit 60eb4cb16c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1163 additions and 81 deletions

View File

@ -1,5 +1,66 @@
# chexpire [![Build Status](https://travis-ci.org/Evolix/chexpire.svg?branch=master)](https://travis-ci.org/Evolix/chexpire)
# Chexpire [![Build Status](https://travis-ci.org/Evolix/chexpire.svg?branch=master)](https://travis-ci.org/Evolix/chexpire)
A web application to help check for domain or SSL/TLS certificate expirations.
![Shakespeare quote: « An SSL error has occured and a secure connection to the server cannot be made. »](app/assets/images/shakespeare_quote_ssl.png)
## How-To Add a new check kind
TL;DR:
- write a job for executing and updating a check instance of your kind and use `CheckLogger` to log important event during a check execution.
- Write a `CheckProcessor` class to perform theses jobs at regular interval.
- For each notification channel, implement the code that notify the user when the date will expires soon or when there are recurrent failures.
Start by appending a new kind into the `Check` model at the `enum :kind` line.
### Write a job and the check execution
The job takes a `Check` instance as an argument and is responsible for performing the check and updating the following dates fields for the check instance:
- mandatory: `last_run_at`, immediately when the execution is starting
- mandatory: `domain_expires_at`
- mandatory: `last_success_at`, when no problems have occured during the verification and fields have been updated
- optional: `domain_created_at`, `domain_updated_at`, when the kind of check allows them
The code architecture is up to you and can vary depending on the check, but most of time you should write a service which will execute something and parse the response. Then you'll return a simple response object which responds to methods used for updating check fields in the job.
Look at the SSL and Domain implementations for examples.
### CheckLogger
This is not currently required, but you should use the `CheckLogger` class to emit log events at important steps of a check execution (especially when error occurs): this can help in case of problems. A single `CheckLogger` instance is attached to a check execution and fill a `CheckLog` model instance with various contents. Looks into the code to see supported events.
### CheckProcessor
A `processor` is responsible for executing checks at regular interval, selectively or not, depending of your needs.
Basically, it performs your job for all relevant checks.
Create a processor dedicated for your check kind. The class should include the `CheckProcessor` module and respond to the following methods :
- `configuration_key`: returns the key name of configuration in chexpire configuration file. For instance: `checks_domain`
- `scope`: returns the checks scope used by each resolver. Generally, this should be the base scope defined into the `CheckProcessor` module, filtered by your check kind (ie: `base_scope.your_kind`)
- `resolvers`: returns an array of methods which returns checks to execute. Allows conditional checks depending of various checks criterias such as far known expiry date, failing checks etc…
Looks into the `CheckProcessor` for available resolvers methods or write your own. use `resolve_all` resolver if you want execute all of your check at each execution.
- `process`: must execute or enqueue your job for a given check as argument. Note: because the processor is called into a task, and to take profit of a interval configuration parameter, it's better to execute the job synchronously in this method.
### Configuration
Each check kind can have configuration. Looks for other checks configuration for examples, such as `checks_domain` configuration in `config/chexpire.example.yml` file.
### Schedule a task
Write a task in `lib/tasks/checks.rake` under the namespaces `checks:sync_dates` and invoke the `sync_dates` method of your processor. Schedule to run it periodically in `config/schedule.rb`, or list the task in the `all` alias which is the default processor scheduler.
### Notifier
Finally, you have to write the way the checks will be notified to theirs users. For each notifier channel (email, …) you need to write two notifications :
- expires_soon
- recurrent_failures
First, add your checks kinds and these notifications definitions in the base class for notifier: `app/services/notifier/base.rb` : in the notify method, for your new check kind, a specific method will be called in each notifier. For example, in the email channel, a specific mailer action is called for each couple (check kin, notification kind).
Then, in each notifier class, implements the details of this method. If you want to ignore a notification for a given channel, simply write the method and do nothing.

43
app/jobs/ssl_sync_job.rb Normal file
View File

@ -0,0 +1,43 @@
class SSLSyncJob < ApplicationJob
queue_as :default
rescue_from StandardError do |exception|
check_logger.log(:standard_error, exception) if check.present?
raise # rubocop:disable Style/SignalException
end
rescue_from ActiveRecord::RecordNotFound do; end
# parser error are already logged
rescue_from SSL::Error do; end
attr_reader :check
def perform(check_id)
prepare_check(check_id)
response = SSL.ask(check.domain, logger: check_logger)
return unless response.valid?
update_from_response(response)
end
def update_from_response(response)
check.domain_expires_at = response.expire_at
check.last_success_at = Time.now
check.save!
end
private
def prepare_check(check_id)
@check = Check.find(check_id)
check.update_attribute(:last_run_at, Time.now)
end
# logger is a reserved ActiveJob method
def check_logger
@check_logger ||= CheckLogger.new(check)
end
end

View File

@ -19,4 +19,16 @@ class NotificationsMailer < ApplicationMailer
subject = t(".subject", domain: @check.domain)
mail subject: subject
end
def ssl_expires_soon
@expire_in_days = Integer(@check.domain_expires_at.to_date - Date.today)
subject = t(".subject", domain: @check.domain, count: @expire_in_days)
mail subject: subject
end
def ssl_recurrent_failures
subject = t(".subject", domain: @check.domain)
mail subject: subject
end
end

View File

@ -0,0 +1,26 @@
class CheckDomainProcessor
include CheckProcessor
protected
def configuration_key
"checks_domain"
end
def resolvers
%i[
resolve_last_run_failed
resolve_expire_short_term
resolve_expire_long_term
resolve_unknown_expiry
]
end
def scope
base_scope.domain
end
def process(check)
WhoisSyncJob.perform_now(check.id)
end
end

View File

@ -1,17 +1,12 @@
class CheckProcessor
module CheckProcessor
attr_reader :configuration
def initialize(configuration = nil)
@configuration = configuration || default_configuration
end
def sync_dates # rubocop:disable Metrics/MethodLength
%i[
resolve_last_run_failed
resolve_expire_short_term
resolve_expire_long_term
resolve_unknown_expiry
].each do |resolver|
def sync_dates
resolvers.each do |resolver|
public_send(resolver).find_each(batch_size: 100).each do |check|
process(check)
@ -20,6 +15,12 @@ class CheckProcessor
end
end
# :nocov:
def resolvers
fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}"
end
# :nocov:
def resolve_last_run_failed
scope.last_run_failed
end
@ -41,25 +42,36 @@ class CheckProcessor
scope.where("domain_expires_at IS NULL")
end
private
def resolve_all
scope
end
def scope
protected
def base_scope
Check
.active
.where("last_run_at IS NULL OR last_run_at < DATE_SUB(NOW(), INTERVAL 12 HOUR)")
end
def process(check)
case check.kind.to_sym
when :domain
WhoisSyncJob.perform_now(check.id)
else
fail ArgumentError, "Unsupported check kind `#{check.kind}`"
end
# :nocov:
def scope
fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}"
end
def process(_check)
fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}"
end
def configuration_key
fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}"
end
# :nocov:
private
def default_configuration
config = Rails.configuration.chexpire.fetch("checks", {})
config = Rails.configuration.chexpire.fetch(configuration_key, {})
OpenStruct.new(
interval: config.fetch("interval") { 0.00 },

View File

@ -0,0 +1,23 @@
class CheckSSLProcessor
include CheckProcessor
protected
def configuration_key
"checks_ssl"
end
def resolvers
%i[
resolve_all
]
end
def scope
base_scope.ssl
end
def process(check)
SSLSyncJob.perform_now(check.id)
end
end

View File

@ -11,6 +11,10 @@ module Notifier
domain_notify_expires_soon(notification)
when [:domain, :recurrent_failures]
domain_notify_recurrent_failures(notification)
when [:ssl, :expires_soon]
ssl_notify_expires_soon(notification)
when [:ssl, :recurrent_failures]
ssl_notify_recurrent_failures(notification)
else
fail ArgumentError,
"Invalid notification reason `#{reason}` for check kind `#{notification.check.kind}`."
@ -25,15 +29,16 @@ module Notifier
"#{self.class.name} channel did not implemented method #{__callee__}"
end
# domain notifications
def domain_notify_expires_soon(_notification)
fail NotImplementedError,
"Channel #{self.class.name} does not implement #{__callee__}"
end
def domain_notify_recurrent_failures(_notification)
fail NotImplementedError,
"Channel #{self.class.name} does not implement #{__callee__}"
%i[
domain_notify_expires_soon
domain_notify_recurrent_failures
ssl_notify_expires_soon
ssl_notify_recurrent_failures
].each do |method|
define_method(method) do
fail NotImplementedError,
"Channel #{self.class.name} does not implement method #{method}."
end
end
# :nocov:
end

View File

@ -16,6 +16,14 @@ module Notifier
def domain_notify_recurrent_failures(notification)
NotificationsMailer.with(notification: notification).domain_recurrent_failures.deliver_now
end
def ssl_notify_expires_soon(notification)
NotificationsMailer.with(notification: notification).ssl_expires_soon.deliver_now
end
def ssl_notify_recurrent_failures(notification)
NotificationsMailer.with(notification: notification).ssl_recurrent_failures.deliver_now
end
end
end
end

66
app/services/ssl.rb Normal file
View File

@ -0,0 +1,66 @@
require "null_logger"
require "system_command"
require_relative "ssl/parser"
require_relative "ssl/response"
require_relative "ssl/errors"
module SSL
class << self
def ask(domain, system_klass: SystemCommand, logger: NullLogger.new)
Service.new(domain, system_klass: system_klass, logger: logger).call
end
end
class Service
attr_reader :domain
attr_reader :logger
attr_reader :system_klass
attr_reader :configuration
def initialize(domain, system_klass: SystemCommand, configuration: nil, logger: NullLogger.new)
@domain = domain
@logger = logger
@system_klass = system_klass
@configuration = configuration || default_configuration
end
def call
result = run_command
parse(result)
rescue StandardError => ex
logger.log :service_error, ex
raise
end
def run_command
command = system_klass.new(check_http_path, check_http_args, logger: logger)
result = command.execute
unless result.exit_status.zero?
fail SSLCommandError, "SSL command failed with status #{result.exit_status}"
end
result
end
def parse(result)
parser = Parser.new(domain, logger: logger)
parser.parse(result.stdout)
end
def check_http_path
configuration.check_http_path.presence || "check_http"
end
def check_http_args
[
configuration.check_http_args.presence,
"-H '#{domain}'",
].compact
end
def default_configuration
OpenStruct.new(Rails.configuration.chexpire.fetch("checks_ssl") { {} })
end
end
end

View File

@ -0,0 +1,10 @@
module SSL
class Error < StandardError; end
class SSLCommandError < Error; end
class ParserError < Error; end
class DomainNotMatchError < ParserError; end
class InvalidResponseError < ParserError; end
class InvalidDateError < ParserError; end
end

View File

@ -0,0 +1,52 @@
require "null_logger"
require "ssl/errors"
module SSL
class Parser
DATE_REGEX = /will expire on (.+)\./
# Several date formats possible:
# OK - Certificate 'domain.net' will expire on Sat 10 Jun 2028 09:14:18 AM GMT +0000.
# OK - Certificate 'domain.net' will expire on 2018-08-06 02:57 +0200/CEST.
attr_reader :logger
attr_reader :domain
def initialize(domain, logger: NullLogger.new)
@logger = logger
@domain = domain
end
def parse(raw)
fail DomainNotMatchError unless match_domain?(raw)
match = raw.match(DATE_REGEX)
fail InvalidResponseError unless match.present?
response = build_response(match)
logger.log :parsed_response, response
response
rescue ParserError => ex
logger.log :parser_error, ex
raise
end
def match_domain?(raw)
raw.match(/\b#{domain}\b/).present?
end
def build_response(match)
Response.new(domain).tap do |response|
response.expire_at = parse_datetime(match[1])
end
end
def parse_datetime(date_str)
Time.parse(date_str).utc
rescue StandardError => ex
raise InvalidDateError, ex.message
end
end
end

View File

@ -0,0 +1,13 @@
module SSL
class Response
attr_accessor :expire_at
def initialize(domain)
@domain = domain
end
def valid?
expire_at.present?
end
end
end

View File

@ -3,7 +3,7 @@
--
<p>
Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %> avant la date d'expiration.</br>
Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %> avant la date d'expiration.</br>
Vous pouvez gérer les notifications pour cette vérification à ce adresse :
<%= link_to nil, edit_check_url(check) %>
</p>

View File

@ -1,6 +1,6 @@
--
Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %> avant
Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %> avant
la date d'expiration.
Vous pouvez gérer les notifications pour cette vérification à ce adresse :
<%= edit_check_url(check) %>

View File

@ -2,7 +2,7 @@
<br />
--
<p>Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %> avant la dernière date d'expiration connue.<br />
<p>Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %> avant la dernière date d'expiration connue.<br />
Vous pouvez gérer les notifications pour cette vérification à ce adresse :
<%= link_to nil, edit_check_url(check) %>
</p>

View File

@ -1,6 +1,6 @@
--
Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %>
Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %>
avant la dernière date d'expiration connue.
Vous pouvez gérer les notifications pour cette vérification à ce adresse :
<%= edit_check_url(check) %>

View File

@ -9,4 +9,4 @@
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", delay: @notification.delay, check: @check %>
<%= render "footer_expires_soon", interval: @notification.interval, check: @check %>

View File

@ -6,4 +6,4 @@ le domaine <%= @check.domain %> va expirer le <%= format_utc(@check.domain_expir
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", delay: @notification.delay, check: @check %>
<%= render "footer_expires_soon", interval: @notification.interval, check: @check %>

View File

@ -8,9 +8,14 @@
</p>
<p>
Our last known expiry date is <%= format_utc(@check.domain_expires_at) %>.
<br />
Our last successful check occured <%= format_utc(@check.last_success_at) %>.
<%- if @check.domain_expires_at.present? %>
Our last known expiry date is <%= format_utc(@check.domain_expires_at) %>.
<br />
<% end %>
<%- if @check.last_success_at.present? %>
Our last successful check occured <%= format_utc(@check.last_success_at) %>.
<% end %>
</p>
<p>

View File

@ -3,8 +3,12 @@ Hi,
We had recurrent failures while checking the whois database for domain
<%= @check.domain %>. As of today, we can't anymore verify the expiry date.
<%- if @check.domain_expires_at.present? %>
The last known expiry date is <%= format_utc(@check.domain_expires_at) %>.
<% end %>
<%- if @check.last_success_at.present? %>
The last successful check occured <%= format_utc(@check.last_success_at) %>.
<% end %>
If you have deleted your domain or have not renewed it, please disable
or delete the check by following this link:

View File

@ -8,9 +8,14 @@
</p>
<p>
La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>.
<br />
Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>.
<%- if @check.domain_expires_at.present? %>
La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>.
<br />
<% end %>
<%- if @check.last_success_at.present? %>
Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>.
<% end %>
</p>
<p>
@ -21,4 +26,4 @@ Si vous avez supprimé le domaine ou ne l'avez pas renouvellé, merci de désact
<%= render "check_comment_vendor" %>
<%= render "footer_recurrent_failures", delay: @notification.delay, check: @check %>
<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %>

View File

@ -4,10 +4,17 @@ Nous avons rencontré de multiples erreurs pendant l'exécution des vérificatio
du domaine <strong><%= @check.domain %></strong>.
Nous ne pouvons plus interroger la base Whois pour vérifier la date d'expiration.
<%- if @check.domain_expires_at.present? %>
La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>.
<% end %>
<%- if @check.last_success_at.present? %>
Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>.
<% end %>
Si vous avez supprimé le domaine ou ne l'avez pas renouvellé,
merci de désactiver la vérification associée, avec ce lien :
<%= edit_check_url(@check) %>
<%= render "check_comment_vendor" %>
<%= render "footer_recurrent_failures", delay: @notification.delay, check: @check %>
<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,12 @@
<p>
Hi,
<br />
<br />
the SSL certificate for <strong><%= @check.domain %></strong> will expire
<strong><%= format_utc(@check.domain_expires_at) %></strong>.
</p>
<br />
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,9 @@
Hi,
the SSL certificate for <%= @check.domain %> will expire <%= format_utc(@check.domain_expires_at) %>.
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,12 @@
<p>
Salut,
<br />
<br />
le certificat SSL pour <strong><%= @check.domain %></strong> va expirer le
<strong><%= format_utc(@check.domain_expires_at) %></strong>.
</p>
<br />
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,9 @@
Salut,
le certificat SSL pour <%= @check.domain %> va expirer le <%= format_utc(@check.domain_expires_at) %>.
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,31 @@
<p>
Hi,
<br />
<br />
We had <strong>recurrent failures</strong> while checking the SSL certificate for
<strong><%= @check.domain %></strong>. As of today, we can no longer verify the certificate
expiry date.
</p>
<p>
<%- if @check.domain_expires_at.present? %>
Our last known expiry date is <%= format_utc(@check.domain_expires_at) %>.
<br />
<%- end %>
<%- if @check.last_success_at.present? %>
Our last successful check occured <%= format_utc(@check.last_success_at) %>.
<%- end %>
</p>
<p>
If there is no more SSL endpoint for this domain, please disable
or delete the check by following this link: <br /><br />
<%= link_to nil, edit_check_url(@check) %>
</p>
<br />
<%= render "check_comment_vendor" %>
<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,22 @@
Hi,
We had recurrent failures while checking the SSL certificate for
<%= @check.domain %>. As of today, we can no longer verify the certificate
expiry date.
<%- if @check.domain_expires_at.present? %>
The last known expiry date is <%= format_utc(@check.domain_expires_at) %>.
<%- end %>
<%- if @check.last_success_at.present? %>
The last successful check occured <%= format_utc(@check.last_success_at) %>.
<%- end %>
If there is no more SSL endpoint for this domain, please disable
or delete the check by following this link:
<%= edit_check_url(@check) %>
<%= render "check_comment_vendor" %>
<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,35 @@
<p>
Salut,
<br />
<br />
Nous avons rencontré de <strong>multiples erreurs</strong>
pendant l'exécution des vérifications du certificat SSL
du site <strong><%= @check.domain %></strong>.
Nous ne pouvons plus vérifier la date d'expiration du certificat
en nous connectant au site.
</p>
<p>
<%- if @check.domain_expires_at.present? %>
La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>.
<br />
<% end %>
<%- if @check.last_success_at.present? %>
Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>.
<% end %>
</p>
<p>
S'il n'y a plus de terminaison SSL pour ce site ou s'il n'existe plus,
merci de désactiver la vérification associée, avec ce lien :
<br />
<br />
<%= link_to nil, edit_check_url(@check) %>
</p>
<br />
<%= render "check_comment_vendor" %>
<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %>

View File

@ -0,0 +1,22 @@
Salut,
Nous avons rencontré de multiples erreurs pendant l'exécution des vérifications
du certificat SSL pour le site <strong><%= @check.domain %></strong>.
Nous ne pouvons plus vérifier la date d'expiration du certificat
en nous connectant au site.
<%- if @check.domain_expires_at.present? %>
La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>.
<% end %>
<%- if @check.last_success_at.present? %>
Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>.
<% end %>
S'il n'y a plus de terminaison SSL pour ce site ou s'il n'existe plus,
merci de désactiver la vérification associée, avec ce lien :
<%= edit_check_url(@check) %>
<%= render "check_comment_vendor" %>
<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %>

View File

@ -3,10 +3,13 @@ default: &default
notifier:
interval: 0.00
failure_days: 3
checks:
checks_domain:
interval: 0.5
long_term: 60
long_term_frequency: 10
checks_ssl:
check_http_path: ""
check_http_args: ""
development:
<<: *default

View File

@ -4,7 +4,10 @@ test:
notifier:
interval: 0.00
failure_days: 3
checks:
checks_domain:
interval: 0.00
long_term: 60
long_term_frequency: 10
checks_ssl:
check_http_path: ""
check_http_args: ""

View File

@ -38,6 +38,15 @@ en:
domain_recurrent_failures:
subject: "Recurrent failures in %{domain} domain expiry check"
ssl_expires_soon:
subject:
zero: "SSL certificate for %{domain} expires TODAY!"
one: "SSL certificate for %{domain} expires TOMORROW!"
other: "SSL certificate for %{domain} expires in %{count} days"
ssl_recurrent_failures:
subject: "Recurrent failures in %{domain} SSL certificate expiry check"
shared:
locales:

View File

@ -12,6 +12,14 @@ fr:
check:
past: "ne peut être dans le futur"
time:
am: am
formats:
default: "%a %d %b %Y %H:%M:%S %z"
long: "%A %d %B %Y %H:%M"
short: "%d %b %H:%M"
pm: pm
devise:
registrations:
new:
@ -36,7 +44,16 @@ fr:
other: "Le domaine %{domain} expire dans %{count} jours"
domain_recurrent_failures:
subject: "Multiples erreur dans la vérification d'expiration du domaine %{domain}"
subject: "Erreurs dans la vérification d'expiration du domaine %{domain}"
ssl_expires_soon:
subject:
zero: "Le certificat SSL pour %{domain} expire AUJOURD'HUI !"
one: "Le certificat SSL pour %{domain} expire DEMAIN !"
other: "Le certificat SSL pour %{domain} expire dans %{count} jours"
ssl_recurrent_failures:
subject: "Erreurs dans la vérification d'expiration du certificat SSL %{domain}"
shared:

View File

@ -19,7 +19,7 @@ set :output, standard: "log/cron.log"
# Learn more: http://github.com/javan/whenever
every 1.day, at: '4:30 am', roles: [:app] do
rake "checks:sync_dates"
rake "checks:sync_dates:all"
end
every 1.day, at: '10:30 am', roles: [:app] do

View File

@ -1,3 +1,4 @@
CheckLog.destroy_all
Notification.destroy_all
Check.destroy_all
User.destroy_all
@ -33,6 +34,31 @@ check_chexpire_org_error = Check.create!(
last_success_at: 4.days.ago,
)
ssl_check_chexpire_org = Check.create!(
user: user1,
kind: :ssl,
domain: "www.chexpire.org",
domain_expires_at: 1.week.from_now,
domain_updated_at: 6.months.ago,
domain_created_at: Time.new(2016, 8, 4, 12, 15, 1),
comment: "The date are fake, this is a seed !",
vendor: "Some random registrar",
)
ssl_check_chexpire_org_error = Check.create!(
user: user1,
kind: :ssl,
domain: "chexpire.org",
domain_expires_at: 1.week.from_now,
domain_updated_at: 6.months.ago,
domain_created_at: Time.new(2016, 8, 4, 12, 15, 1),
comment: "The date are fake, this is a seed !",
vendor: "Some random registrar",
last_run_at: 20.minutes.ago,
last_success_at: 4.days.ago,
)
Notification.create!(
check: check_chexpire_org,
interval: 15,
@ -49,6 +75,22 @@ Notification.create!(
status: :pending,
)
Notification.create!(
check: ssl_check_chexpire_org,
interval: 15,
channel: :email,
recipient: "colin@example.org",
status: :pending,
)
Notification.create!(
check: ssl_check_chexpire_org_error,
interval: 15,
channel: :email,
recipient: "colin@example.org",
status: :pending,
)
puts "\e[0;32mDone 👌\e[0m"
puts " "
puts "--------------------"

View File

@ -1,7 +1,17 @@
namespace :checks do
desc "Refresh expiry dates for checks"
task sync_dates: :environment do
process = CheckProcessor.new
process.sync_dates
namespace :sync_dates do
task all: [:domain, :ssl]
desc "Refresh domains expiry dates"
task domain: :environment do
process = CheckDomainProcessor.new
process.sync_dates
end
desc "Refresh SSL expiry dates"
task domain: :environment do
process = CheckSSLProcessor.new
process.sync_dates
end
end
end

View File

@ -44,6 +44,10 @@ FactoryBot.define do
kind :domain
end
trait :ssl do
kind :ssl
end
trait :nil_dates do
domain_created_at nil
domain_updated_at nil

View File

@ -0,0 +1 @@
OK - Certificate 'ssl0.domain.org' will expire on Sat 10 Jun 2028 09:14:18 AM GMT +0000.

View File

@ -0,0 +1 @@
SSL OK - Certificate 'ssl1.domain.org' will expire on 2022-08-06 02:57 +0200/CEST.

View File

@ -0,0 +1 @@
check_http: Invalid hostname/address - ssl100.invalid.org

View File

@ -0,0 +1 @@
SSL OK - Certificate 'ssl101.invalid.org' will expire on unknown date.

View File

@ -0,0 +1,68 @@
require "test_helper"
class SSLSyncJobTest < ActiveJob::TestCase
test "calls whois database and update check with the response (domain.fr)" do
domain = "ssl0.domain.org"
check = create(:check, :nil_dates, domain: domain)
mock_system_command("check_http", expected_command_arg(domain), stdout: ssl_response(domain)) do
SSLSyncJob.perform_now(check.id)
end
check.reload
assert_just_now check.last_run_at
assert_just_now check.last_success_at
assert_equal Time.new(2028, 6, 10, 9, 14, 18, 0), check.domain_expires_at
assert_nil check.domain_updated_at
assert_nil check.domain_created_at
assert check.active?
end
test "ignore invalid response" do
domain = "domain.fr"
check = create(:check, :nil_dates, domain: domain)
original_updated_at = check.updated_at
mock_system_command("check_http", expected_command_arg(domain), stdout: "not a response") do
SSLSyncJob.perform_now(check.id)
end
check.reload
assert_just_now check.last_run_at
assert_nil check.last_success_at
assert_equal original_updated_at, check.updated_at
assert check.active?
end
test "should ignore not found (removed) checks" do
assert_nothing_raised do
SSLSyncJob.perform_now("9999999")
end
end
test "should log and re-raise StandardError" do
check = create(:check)
assert_raise StandardError do
SSL.stub :ask, nil do
SSLSyncJob.perform_now(check.id)
end
end
assert_equal 1, check.logs.count
assert_match(/undefined method \W+valid\?/, check.logs.last.error)
assert check.logs.last.failed?
end
private
def ssl_response(domain)
file_fixture("ssl/#{domain}.txt").read
end
def expected_command_arg(domain)
["-H '#{domain}'"]
end
end

View File

@ -1,6 +1,6 @@
require "test_helper"
class NotificationsMailerTest < ActionMailer::TestCase
class NotificationsMailerTest < ActionMailer::TestCase # rubocop:disable Metrics/ClassLength
test "domain_expires_soon" do
check = create(:check, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00"))
notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org")
@ -17,7 +17,7 @@ class NotificationsMailerTest < ActionMailer::TestCase
assert_equal ["colin@example.org"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.body.to_s, mail.html_part.to_s]
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "domain.fr", part
@ -30,6 +30,37 @@ class NotificationsMailerTest < ActionMailer::TestCase
end
end
test "domain_expires_soon FR" do
check = create(:check, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00"))
notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org")
I18n.with_locale :fr do
Date.stub :today, Date.new(2018, 6, 2) do
mail = NotificationsMailer.with(notification: notification).domain_expires_soon
assert_emails 1 do
mail.deliver_now
end
assert_match "domain.fr", mail.subject
assert_match "dans 8 jours", mail.subject
assert_equal ["colin@example.org"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "domain.fr", part
assert_match "dim 10 juin 2018 10:00:05 +0000", part
assert_match "10 jours", part
assert_match "/checks/#{check.id}/edit", part
assert_no_match "commentaire", part
assert_no_match "fournisseur", part
end
end
end
end
test "domain_expires_soon include comment & vendor" do
check = create(:check,
domain_expires_at: 1.week.from_now,
@ -39,7 +70,7 @@ class NotificationsMailerTest < ActionMailer::TestCase
mail = NotificationsMailer.with(notification: notification).domain_expires_soon
parts = [mail.text_part.body.to_s, mail.html_part.to_s]
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "My comment", part
@ -47,6 +78,25 @@ class NotificationsMailerTest < ActionMailer::TestCase
end
end
test "domain_expires_soon include comment & vendor - FR" do
check = create(:check,
domain_expires_at: 1.week.from_now,
comment: "My comment",
vendor: "The vendor")
notification = build(:notification, check: check)
I18n.with_locale :fr do
mail = NotificationsMailer.with(notification: notification).domain_expires_soon
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "commentaire", part
assert_match "Fournisseur", part
end
end
end
test "domain_recurrent_failures" do
last_success_at = Time.new(2018, 5, 30, 6, 10, 0, "+00:00")
domain_expires_at = Time.new(2018, 10, 10, 7, 20, 0, "+04:00")
@ -64,7 +114,7 @@ class NotificationsMailerTest < ActionMailer::TestCase
assert_equal ["recipient@domain.fr"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.body.to_s, mail.html_part.to_s]
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "invalid-domain.fr", part
@ -75,4 +125,159 @@ class NotificationsMailerTest < ActionMailer::TestCase
assert_match "/checks/#{check.id}/edit", part
end
end
test "domain_recurrent_failures - FR" do
last_success_at = Time.new(2018, 5, 30, 6, 10, 0, "+00:00")
domain_expires_at = Time.new(2018, 10, 10, 7, 20, 0, "+04:00")
check = build(:check, :last_runs_failed,
domain: "invalid-domain.fr",
last_success_at: last_success_at,
domain_expires_at: domain_expires_at,
comment: "My comment")
notification = create(:notification, check: check)
I18n.with_locale :fr do
mail = NotificationsMailer.with(notification: notification).domain_recurrent_failures
assert_match "Erreurs", mail.subject
assert_match "invalid-domain.fr", mail.subject
assert_equal ["recipient@domain.fr"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "invalid-domain.fr", part
assert_match "erreurs", part
assert_match(/réussie[a-z ]+ mer 30 mai 2018 06:10:00 \+0000/, part)
assert_match(/expiration[a-z ]+ mer 10 oct. 2018 03:20:00 \+0000/, part)
assert_match "commentaire", part
assert_match "/checks/#{check.id}/edit", part
end
end
end
test "ssl_expires_soon" do
check = create(:check, :ssl, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00"))
notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org")
Date.stub :today, Date.new(2018, 6, 2) do
mail = NotificationsMailer.with(notification: notification).ssl_expires_soon
assert_emails 1 do
mail.deliver_now
end
assert_match "domain.fr", mail.subject
assert_match "SSL", mail.subject
assert_match "in 8 days", mail.subject
assert_equal ["colin@example.org"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "domain.fr", part
assert_match "Sun, 10 Jun 2018 10:00:05 +0000", part
assert_match "10 days", part
assert_match "/checks/#{check.id}/edit", part
assert_no_match "comment", part
assert_no_match "vendor", part
end
end
end
test "ssl_expires_soon - FR" do
check = create(:check, :ssl, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00"))
notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org")
I18n.with_locale :fr do
Date.stub :today, Date.new(2018, 6, 2) do
mail = NotificationsMailer.with(notification: notification).ssl_expires_soon
assert_emails 1 do
mail.deliver_now
end
assert_match "domain.fr", mail.subject
assert_match "SSL", mail.subject
assert_match "dans 8 jours", mail.subject
assert_equal ["colin@example.org"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "domain.fr", part
assert_match "dim 10 juin 2018 10:00:05 +0000", part
assert_match "10 jours", part
assert_match "/checks/#{check.id}/edit", part
assert_no_match "commentaire", part
assert_no_match "fournisseur", part
end
end
end
end
test "ssl_recurrent_failures" do
last_success_at = Time.new(2018, 5, 30, 6, 10, 0, "+00:00")
domain_expires_at = Time.new(2018, 10, 10, 7, 20, 0, "+04:00")
check = build(:check, :ssl, :last_runs_failed,
domain: "invalid-domain.fr",
last_success_at: last_success_at,
domain_expires_at: domain_expires_at,
comment: "My comment")
notification = create(:notification, check: check)
mail = NotificationsMailer.with(notification: notification).ssl_recurrent_failures
assert_match "failures", mail.subject
assert_match "invalid-domain.fr", mail.subject
assert_match "SSL", mail.subject
assert_equal ["recipient@domain.fr"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "invalid-domain.fr", part
assert_match "recurrent failures", part
assert_match(/success[a-z ]+ Wed, 30 May 2018 06:10:00 \+0000/, part)
assert_match(/expiry[a-z ]+ Wed, 10 Oct 2018 03:20:00 \+0000/, part)
assert_match "My comment", part
assert_match "/checks/#{check.id}/edit", part
end
end
test "ssl_recurrent_failures - FR" do
last_success_at = Time.new(2018, 5, 30, 6, 10, 0, "+00:00")
domain_expires_at = Time.new(2018, 10, 10, 7, 20, 0, "+04:00")
check = build(:check, :ssl, :last_runs_failed,
domain: "invalid-domain.fr",
last_success_at: last_success_at,
domain_expires_at: domain_expires_at,
comment: "My comment")
notification = create(:notification, check: check)
I18n.with_locale :fr do
mail = NotificationsMailer.with(notification: notification).ssl_recurrent_failures
assert_match "Erreurs", mail.subject
assert_match "invalid-domain.fr", mail.subject
assert_match "SSL", mail.subject
assert_equal ["recipient@domain.fr"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.decode_body, mail.html_part.decode_body]
parts.each do |part|
assert_match "invalid-domain.fr", part
assert_match "erreurs", part
assert_match(/réussie[a-z ]+ mer 30 mai 2018 06:10:00 \+0000/, part)
assert_match(/expiration[a-z ]+ mer 10 oct. 2018 03:20:00 \+0000/, part)
assert_match "commentaire", part
assert_match "/checks/#{check.id}/edit", part
end
end
end
end

View File

@ -2,12 +2,25 @@
class NotificationsMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/domain_expires_soon
def domain_expires_soon
NotificationsMailer.with(notification: Notification.first).domain_expires_soon
check = Check.domain.first
NotificationsMailer.with(notification: check.notifications.first).domain_expires_soon
end
# Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/domain_recurrent_failures
def domain_recurrent_failures
check = Check.where("last_run_at != last_success_at").limit(1).first
check = Check.domain.where("last_run_at != last_success_at").first
NotificationsMailer.with(notification: check.notifications.first).domain_recurrent_failures
end
# Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/ssl_expires_soon
def ssl_expires_soon
check = Check.ssl.first
NotificationsMailer.with(notification: check.notifications.first).ssl_expires_soon
end
# Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/ssl_recurrent_failures
def ssl_recurrent_failures
check = Check.ssl.where("last_run_at != last_success_at").first
NotificationsMailer.with(notification: check.notifications.first).ssl_recurrent_failures
end
end

View File

@ -0,0 +1,34 @@
require "test_helper"
class CheckDomainProcessorTest < ActiveSupport::TestCase
setup do
@processor = CheckDomainProcessor.new
end
test "process WhoisSyncJob for domain checks" do
domain = "domain.fr"
check = create(:check, :domain, :nil_dates, domain: domain)
mock_system_command("whois", domain, stdout: file_fixture("whois/domain.fr.txt").read) do
@processor.send(:process, check)
end
check.reload
assert_equal Time.new(2019, 2, 17, 0, 0, 0, 0), check.domain_expires_at
end
test "scope concerns only checks of kind 'domain'" do
domains = create_list(:check, 2, :domain)
create_list(:check, 2, :ssl)
assert_equal domains, @processor.send(:scope)
end
test "resolvers returns an array of methods returning a scope" do
assert_not_empty @processor.send(:resolvers)
@processor.send(:resolvers).each do |method|
assert_kind_of ActiveRecord::Relation, @processor.public_send(method)
end
end
end

View File

@ -1,31 +1,26 @@
require "test_helper"
class CheckDummyProcessor
include CheckProcessor
def scope
base_scope
end
def configuration_key
"checks_dummy"
end
def resolvers
%i[
resolve_expire_short_term
resolve_expire_long_term
]
end
end
class CheckProcessorTest < ActiveSupport::TestCase
setup do
@processor = CheckProcessor.new
end
test "process WhoisSyncJob for domain checks" do
domain = "domain.fr"
check = create(:check, :domain, :nil_dates, domain: domain)
mock_system_command("whois", domain, stdout: file_fixture("whois/domain.fr.txt").read) do
@processor.send(:process, check)
end
check.reload
assert_equal Time.new(2019, 2, 17, 0, 0, 0, 0), check.domain_expires_at
end
test "raises an error for an unsupported check kind" do
check = build(:check)
check.stub :kind, :unknown do
assert_raises ArgumentError do
@processor.send(:process, check)
end
end
@processor = CheckDummyProcessor.new
end
test "resolve_last_run_failed includes already and never succeeded" do
@ -74,6 +69,14 @@ class CheckProcessorTest < ActiveSupport::TestCase
assert_not_includes checks, c4
end
test "resolve_all include all eligible checks" do
create(:check, :expires_next_week)
create(:check, :expires_next_week, last_run_at: 4.hours.ago)
create(:check, :last_runs_failed)
assert_equal @processor.send(:scope), @processor.resolve_all
end
test "resolvers does not include checks recently executed" do
c1 = create(:check, :expires_next_week)
c2 = create(:check, :expires_next_week, last_run_at: 4.hours.ago)
@ -113,7 +116,7 @@ class CheckProcessorTest < ActiveSupport::TestCase
configuration.expect(:interval, 0.000001)
end
processor = CheckProcessor.new(configuration)
processor = CheckDummyProcessor.new(configuration)
mock = Minitest::Mock.new
assert_stub = lambda { |actual_time|

View File

@ -0,0 +1,35 @@
require "test_helper"
class CheckSSLProcessorTest < ActiveSupport::TestCase
setup do
@processor = CheckSSLProcessor.new
end
test "process SSLSyncJob for ssl checks" do
domain = "ssl0.domain.org"
check = create(:check, :ssl, :nil_dates, domain: domain)
response = file_fixture("ssl/ssl0.domain.org.txt").read
mock_system_command("check_http", ["-H '#{domain}'"], stdout: response) do
@processor.send(:process, check)
end
check.reload
assert_equal Time.new(2028, 6, 10, 9, 14, 18, 0), check.domain_expires_at
end
test "scope concerns only checks of kind 'ssl'" do
domains = create_list(:check, 2, :ssl)
create_list(:check, 2, :domain)
assert_equal domains, @processor.send(:scope)
end
test "resolvers returns an array of methods returning a scope" do
assert_not_empty @processor.send(:resolvers)
@processor.send(:resolvers).each do |method|
assert_kind_of ActiveRecord::Relation, @processor.public_send(method)
end
end
end

View File

@ -0,0 +1,53 @@
require "test_helper"
require "ssl/parser"
require "ssl/errors"
module SSL
class ParserTest < ActiveSupport::TestCase
test "should parse a SSL check response" do
parser = Parser.new("ssl0.domain.org")
domain = file_fixture("ssl/ssl0.domain.org.txt").read
response = parser.parse(domain)
assert_kind_of Response, response
assert_equal Time.new(2028, 6, 10, 9, 14, 18, 0), response.expire_at
assert response.expire_at.utc?
end
test "should parse a SSL check response in another format and convert it in UTC" do
parser = Parser.new("ssl1.domain.org")
domain = file_fixture("ssl/ssl1.domain.org.txt").read
response = parser.parse(domain)
assert_kind_of Response, response
assert_equal Time.new(2022, 8, 6, 0, 57, 0, 0), response.expire_at
assert response.expire_at.utc?
end
test "should raises DomainNotMatchError when parsed text does not match the domain" do
parser = Parser.new("anotherdomain.fr")
output = file_fixture("ssl/ssl1.domain.org.txt").read
assert_raises DomainNotMatchError do
parser.parse(output)
end
end
test "should raises InvalidResponseError when check response is not matched" do
parser = Parser.new("ssl100.invalid.org")
output = file_fixture("ssl/ssl100.invalid.org.txt").read
assert_raises InvalidResponseError do
parser.parse(output)
end
end
test "should raises InvalidDateError when a date is not in the expected format" do
parser = Parser.new("ssl101.invalid.org")
output = file_fixture("ssl/ssl101.invalid.org.txt").read
assert_raises InvalidDateError do
parser.parse(output)
end
end
end
end

74
test/services/ssl_test.rb Normal file
View File

@ -0,0 +1,74 @@
require "test_helper"
require "ssl"
require "system_command"
module SSL
class ServiceTest < ActiveSupport::TestCase
test "should run the command, return the result" do
result = OpenStruct.new(exit_status: 0)
mock_system_klass("check_http", ["-H 'example.org'"], result) do |system_klass|
service = Service.new("example.org", system_klass: system_klass)
assert_equal result, service.run_command
end
end
test "should raise an exception if exit status > 0" do
result = OpenStruct.new(exit_status: 1)
mock_system_klass("check_http", ["-H 'example.org'"], result) do |system_klass|
service = Service.new("example.org", system_klass: system_klass)
assert_raises SSLCommandError do
service.run_command
end
end
end
test "should parse from a command result" do
result = OpenStruct.new(
exit_status: 0,
stdout: file_fixture("ssl/ssl0.domain.org.txt").read,
)
service = Service.new("ssl0.domain.org")
assert_kind_of Response, service.parse(result)
end
test "should uses the command line arguments of the configuration" do
result = OpenStruct.new(exit_status: 0)
config = OpenStruct.new(check_http_args: "-f follow -I 127.0.0.1")
expected_args = ["-f follow -I 127.0.0.1", "-H 'example.org'"]
mock_system_klass("check_http", expected_args, result) do |system_klass|
service = Service.new("example.org", configuration: config, system_klass: system_klass)
assert_equal result, service.run_command
end
end
test "should uses the program path from the configuration" do
result = OpenStruct.new(exit_status: 0)
config = OpenStruct.new(check_http_path: "/usr/local/custom/path")
mock_system_klass("/usr/local/custom/path", ["-H 'example.org'"], result) do |system_klass|
service = Service.new("example.org", configuration: config, system_klass: system_klass)
assert_equal result, service.run_command
end
end
def mock_system_klass(program, command_args, result)
system_klass = Minitest::Mock.new
system_command = Minitest::Mock.new.expect(:execute, result)
system_klass.expect(:new, system_command) do |arg1, arg2, logger:|
arg1 == program &&
arg2 == command_args &&
logger.class == NullLogger
end
yield system_klass
system_klass.verify
system_command.verify
end
end
end

View File

@ -6,6 +6,7 @@ if !ENV["NO_COVERAGE"] && (ARGV.empty? || ARGV.include?("test/test_helper.rb"))
SimpleCov.start "rails" do
add_group "Notifier", "app/services/notifier"
add_group "Whois", "app/services/whois"
add_group "SSL", "app/services/ssl"
add_group "Services", "app/services"
add_group "Policies", "app/policies"
end