From 8a9a7f6f22ba88d2558c513db9c709f8c709d2e0 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 2 Jul 2018 17:21:08 +0200 Subject: [PATCH] SSL Checks with check_http --- app/jobs/ssl_sync_job.rb | 43 +++++++++++ app/services/check_domain_processor.rb | 1 + app/services/check_processor.rb | 6 +- app/services/check_ssl_processor.rb | 23 ++++++ app/services/ssl.rb | 66 +++++++++++++++++ app/services/ssl/errors.rb | 10 +++ app/services/ssl/parser.rb | 52 +++++++++++++ app/services/ssl/response.rb | 13 ++++ config/chexpire.example.yml | 3 + config/chexpire.test.yml | 3 + lib/tasks/checks.rake | 8 +- test/fixtures/files/ssl/ssl0.domain.org.txt | 1 + test/fixtures/files/ssl/ssl1.domain.org.txt | 1 + .../fixtures/files/ssl/ssl100.invalid.org.txt | 1 + .../fixtures/files/ssl/ssl101.invalid.org.txt | 1 + test/jobs/ssl_sync_job_test.rb | 68 +++++++++++++++++ test/services/check_processor_test.rb | 8 ++ test/services/check_ssl_processor_test.rb | 35 +++++++++ test/services/ssl/parser_test.rb | 53 +++++++++++++ test/services/ssl_test.rb | 74 +++++++++++++++++++ test/test_helper.rb | 1 + 21 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 app/jobs/ssl_sync_job.rb create mode 100644 app/services/check_ssl_processor.rb create mode 100644 app/services/ssl.rb create mode 100644 app/services/ssl/errors.rb create mode 100644 app/services/ssl/parser.rb create mode 100644 app/services/ssl/response.rb create mode 100644 test/fixtures/files/ssl/ssl0.domain.org.txt create mode 100644 test/fixtures/files/ssl/ssl1.domain.org.txt create mode 100644 test/fixtures/files/ssl/ssl100.invalid.org.txt create mode 100644 test/fixtures/files/ssl/ssl101.invalid.org.txt create mode 100644 test/jobs/ssl_sync_job_test.rb create mode 100644 test/services/check_ssl_processor_test.rb create mode 100644 test/services/ssl/parser_test.rb create mode 100644 test/services/ssl_test.rb diff --git a/app/jobs/ssl_sync_job.rb b/app/jobs/ssl_sync_job.rb new file mode 100644 index 0000000..6c47679 --- /dev/null +++ b/app/jobs/ssl_sync_job.rb @@ -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 diff --git a/app/services/check_domain_processor.rb b/app/services/check_domain_processor.rb index 356e5aa..183fc03 100644 --- a/app/services/check_domain_processor.rb +++ b/app/services/check_domain_processor.rb @@ -1,5 +1,6 @@ class CheckDomainProcessor include CheckProcessor + protected def configuration_key diff --git a/app/services/check_processor.rb b/app/services/check_processor.rb index 15c1e2e..abe087c 100644 --- a/app/services/check_processor.rb +++ b/app/services/check_processor.rb @@ -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 diff --git a/app/services/check_ssl_processor.rb b/app/services/check_ssl_processor.rb new file mode 100644 index 0000000..f36e7c5 --- /dev/null +++ b/app/services/check_ssl_processor.rb @@ -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 diff --git a/app/services/ssl.rb b/app/services/ssl.rb new file mode 100644 index 0000000..95af40f --- /dev/null +++ b/app/services/ssl.rb @@ -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 diff --git a/app/services/ssl/errors.rb b/app/services/ssl/errors.rb new file mode 100644 index 0000000..6982c62 --- /dev/null +++ b/app/services/ssl/errors.rb @@ -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 diff --git a/app/services/ssl/parser.rb b/app/services/ssl/parser.rb new file mode 100644 index 0000000..68dffe8 --- /dev/null +++ b/app/services/ssl/parser.rb @@ -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 diff --git a/app/services/ssl/response.rb b/app/services/ssl/response.rb new file mode 100644 index 0000000..762c498 --- /dev/null +++ b/app/services/ssl/response.rb @@ -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 diff --git a/config/chexpire.example.yml b/config/chexpire.example.yml index 1daaa4a..b322b96 100644 --- a/config/chexpire.example.yml +++ b/config/chexpire.example.yml @@ -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 diff --git a/config/chexpire.test.yml b/config/chexpire.test.yml index dc99552..2a72a7b 100644 --- a/config/chexpire.test.yml +++ b/config/chexpire.test.yml @@ -8,3 +8,6 @@ test: interval: 0.00 long_term: 60 long_term_frequency: 10 + checks_ssl: + check_http_path: "" + check_http_args: "" diff --git a/lib/tasks/checks.rake b/lib/tasks/checks.rake index a37eb60..6703e73 100644 --- a/lib/tasks/checks.rake +++ b/lib/tasks/checks.rake @@ -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 diff --git a/test/fixtures/files/ssl/ssl0.domain.org.txt b/test/fixtures/files/ssl/ssl0.domain.org.txt new file mode 100644 index 0000000..8663a1d --- /dev/null +++ b/test/fixtures/files/ssl/ssl0.domain.org.txt @@ -0,0 +1 @@ +OK - Certificate 'ssl0.domain.org' will expire on Sat 10 Jun 2028 09:14:18 AM GMT +0000. diff --git a/test/fixtures/files/ssl/ssl1.domain.org.txt b/test/fixtures/files/ssl/ssl1.domain.org.txt new file mode 100644 index 0000000..91b39ee --- /dev/null +++ b/test/fixtures/files/ssl/ssl1.domain.org.txt @@ -0,0 +1 @@ +SSL OK - Certificate 'ssl1.domain.org' will expire on 2022-08-06 02:57 +0200/CEST. diff --git a/test/fixtures/files/ssl/ssl100.invalid.org.txt b/test/fixtures/files/ssl/ssl100.invalid.org.txt new file mode 100644 index 0000000..3153b3e --- /dev/null +++ b/test/fixtures/files/ssl/ssl100.invalid.org.txt @@ -0,0 +1 @@ +check_http: Invalid hostname/address - ssl100.invalid.org diff --git a/test/fixtures/files/ssl/ssl101.invalid.org.txt b/test/fixtures/files/ssl/ssl101.invalid.org.txt new file mode 100644 index 0000000..1698540 --- /dev/null +++ b/test/fixtures/files/ssl/ssl101.invalid.org.txt @@ -0,0 +1 @@ +SSL OK - Certificate 'ssl101.invalid.org' will expire on unknown date. diff --git a/test/jobs/ssl_sync_job_test.rb b/test/jobs/ssl_sync_job_test.rb new file mode 100644 index 0000000..0a55cdb --- /dev/null +++ b/test/jobs/ssl_sync_job_test.rb @@ -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 diff --git a/test/services/check_processor_test.rb b/test/services/check_processor_test.rb index 9751ec5..d5c1387 100644 --- a/test/services/check_processor_test.rb +++ b/test/services/check_processor_test.rb @@ -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) diff --git a/test/services/check_ssl_processor_test.rb b/test/services/check_ssl_processor_test.rb new file mode 100644 index 0000000..db6fa3c --- /dev/null +++ b/test/services/check_ssl_processor_test.rb @@ -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 diff --git a/test/services/ssl/parser_test.rb b/test/services/ssl/parser_test.rb new file mode 100644 index 0000000..05566d6 --- /dev/null +++ b/test/services/ssl/parser_test.rb @@ -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 diff --git a/test/services/ssl_test.rb b/test/services/ssl_test.rb new file mode 100644 index 0000000..5b98396 --- /dev/null +++ b/test/services/ssl_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index fda0a43..91ae325 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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