SSL Checks with check_http

This commit is contained in:
Colin Darie 2018-07-02 17:21:08 +02:00
parent db4e7d42b2
commit 8a9a7f6f22
No known key found for this signature in database
GPG Key ID: 4FB865FDBCA4BCC4
21 changed files with 469 additions and 2 deletions

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

@ -1,5 +1,6 @@
class CheckDomainProcessor
include CheckProcessor
protected
def configuration_key

View File

@ -42,6 +42,10 @@ module CheckProcessor
scope.where("domain_expires_at IS NULL")
end
def resolve_all
scope
end
protected
def base_scope
@ -55,7 +59,7 @@ module CheckProcessor
fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}"
end
def process(check)
def process(_check)
fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}"
end

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

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

@ -7,6 +7,9 @@ default: &default
interval: 0.5
long_term: 60
long_term_frequency: 10
checks_ssl:
check_http_path: ""
check_http_args: ""
development:
<<: *default

View File

@ -8,3 +8,6 @@ test:
interval: 0.00
long_term: 60
long_term_frequency: 10
checks_ssl:
check_http_path: ""
check_http_args: ""

View File

@ -1,11 +1,17 @@
namespace :checks do
namespace :sync_dates do
task all: [:domain]
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

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

@ -69,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)

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