mirror of
https://github.com/Evolix/chexpire.git
synced 2024-05-04 09:45:09 +02:00
SSL Checks with check_http
This commit is contained in:
parent
db4e7d42b2
commit
8a9a7f6f22
43
app/jobs/ssl_sync_job.rb
Normal file
43
app/jobs/ssl_sync_job.rb
Normal 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
|
|
@ -1,5 +1,6 @@
|
|||
class CheckDomainProcessor
|
||||
include CheckProcessor
|
||||
|
||||
protected
|
||||
|
||||
def configuration_key
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
23
app/services/check_ssl_processor.rb
Normal file
23
app/services/check_ssl_processor.rb
Normal 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
66
app/services/ssl.rb
Normal 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
|
10
app/services/ssl/errors.rb
Normal file
10
app/services/ssl/errors.rb
Normal 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
|
52
app/services/ssl/parser.rb
Normal file
52
app/services/ssl/parser.rb
Normal 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
|
13
app/services/ssl/response.rb
Normal file
13
app/services/ssl/response.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -8,3 +8,6 @@ test:
|
|||
interval: 0.00
|
||||
long_term: 60
|
||||
long_term_frequency: 10
|
||||
checks_ssl:
|
||||
check_http_path: ""
|
||||
check_http_args: ""
|
||||
|
|
|
@ -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
|
||||
|
|
1
test/fixtures/files/ssl/ssl0.domain.org.txt
vendored
Normal file
1
test/fixtures/files/ssl/ssl0.domain.org.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
OK - Certificate 'ssl0.domain.org' will expire on Sat 10 Jun 2028 09:14:18 AM GMT +0000.
|
1
test/fixtures/files/ssl/ssl1.domain.org.txt
vendored
Normal file
1
test/fixtures/files/ssl/ssl1.domain.org.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
SSL OK - Certificate 'ssl1.domain.org' will expire on 2022-08-06 02:57 +0200/CEST.
|
1
test/fixtures/files/ssl/ssl100.invalid.org.txt
vendored
Normal file
1
test/fixtures/files/ssl/ssl100.invalid.org.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
check_http: Invalid hostname/address - ssl100.invalid.org
|
1
test/fixtures/files/ssl/ssl101.invalid.org.txt
vendored
Normal file
1
test/fixtures/files/ssl/ssl101.invalid.org.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
SSL OK - Certificate 'ssl101.invalid.org' will expire on unknown date.
|
68
test/jobs/ssl_sync_job_test.rb
Normal file
68
test/jobs/ssl_sync_job_test.rb
Normal 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
|
|
@ -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)
|
||||
|
|
35
test/services/check_ssl_processor_test.rb
Normal file
35
test/services/check_ssl_processor_test.rb
Normal 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
|
53
test/services/ssl/parser_test.rb
Normal file
53
test/services/ssl/parser_test.rb
Normal 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
74
test/services/ssl_test.rb
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue