From 1ac34799967600a55368bd6b737bcd2c688534e6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 6 Nov 2017 10:53:29 +0100 Subject: [PATCH] Store certificates in database and add custom provider * Read certbot generated files and store certificates in database. * Allow creating custom certificates with manually entered certificate information. * Add Rake task to reinstall all certificates from the database- * Add Rake task to read installed certificates from the certbot output directory to upgrade a previous `cert_watch` install. --- .rubocop.yml | 3 + Gemfile | 1 + README.md | 34 +++- admin/cert_watch/certificates.rb | 13 +- .../cert_watch/install_certificate_job.rb | 8 +- app/jobs/cert_watch/renew_certificate_job.rb | 5 +- .../renew_expiring_certificates_job.rb | 1 + app/models/cert_watch/certificate.rb | 30 ++++ config/locales/de.yml | 4 + config/locales/en.yml | 4 + ...23122819_add_key_fields_to_certificates.rb | 7 + ...1024073152_add_provider_to_certificates.rb | 5 + lib/cert_watch.rb | 13 +- lib/cert_watch/certbot_client.rb | 15 ++ lib/cert_watch/client.rb | 4 + lib/cert_watch/configuration.rb | 4 + lib/cert_watch/domain_owner.rb | 6 +- lib/cert_watch/installer.rb | 2 +- lib/cert_watch/pem_directory_installer.rb | 34 ++-- lib/cert_watch/shell.rb | 9 +- lib/cert_watch/tasks.rb | 26 +++ spec/cert_watch/certbot_client_spec.rb | 23 +++ spec/cert_watch/domain_owner_spec.rb | 21 +++ .../pem_directory_installer_spec.rb | 85 +++++----- spec/cert_watch/shell_spec.rb | 24 +++ spec/cert_watch/tasks_spec.rb | 31 ++++ .../certificates_controller_spec.rb | 18 +++ spec/factories/certificates.rb | 17 ++ .../app/controllers/application_controller.rb | 2 + .../config/initializers/active_admin.rb | 1 + spec/internal/config/routes.rb | 2 +- spec/models/cert_watch/certificate_spec.rb | 152 +++++++++++++++++- spec/support/config/cert_watch.rb | 2 + spec/support/helpers/doubles.rb | 12 +- 34 files changed, 540 insertions(+), 78 deletions(-) create mode 100644 db/migrate/20171023122819_add_key_fields_to_certificates.rb create mode 100644 db/migrate/20171024073152_add_provider_to_certificates.rb create mode 100644 lib/cert_watch/tasks.rb create mode 100644 spec/cert_watch/tasks_spec.rb create mode 100644 spec/controllers/admin/cert_watch/certificates_controller_spec.rb create mode 100644 spec/internal/app/controllers/application_controller.rb create mode 100644 spec/internal/config/initializers/active_admin.rb diff --git a/.rubocop.yml b/.rubocop.yml index 6b59aec..338ab3a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,6 +2,9 @@ # checks locally. inherit_from: ./.rubocop_hound.yml +AllCops: + TargetRubyVersion: 2.1 + # Use double quotes only for interpolation. Style/StringLiterals: EnforcedStyle: single_quotes diff --git a/Gemfile b/Gemfile index 58033dd..7b8b4e0 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,7 @@ source 'https://rubygems.org' gemspec +gem 'activeadmin' gem 'state_machine', git: 'https://github.com/codevise/state_machine.git' gem 'coveralls', require: false diff --git a/README.md b/README.md index a143ff3..0556983 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ CertWatch consists of the following components: * Resque jobs to renew and install certificates. * A mixin for models with a `cname` attribute to request certificats on attribute change. +* Rake tasks to reinstall certificates on a fresh server Optionally: @@ -76,13 +77,27 @@ CertWatch.setup do |config| # config.certbot_output_directory = '/etc/letsencrypt/live' # Directory the web server reads pem files from - # config.pem_directory = '/etc/haproxy/ssl/ + # config.pem_directory = '/etc/haproxy/ssl/' + + # Place pem files in provider specific subdirectories of pem directory. + # By default, all pem files are placed in pem directory itself. + # config.provider_install_directory_mapping = { + # certbot: 'letsencrypt', + # custom: 'custom' + # } # Command to make server reload pem files # config.server_reload_command = '/etc/init.d/haproxy reload' end ``` +Ensure private keys do not show up in log files: + +```ruby +# config/initializers/filter_parameter_logging.rb +Rails.application.config.filter_parameters += [:private_key] +``` + Include the `DomainOwner` mixin into a model with a domain attribute. This makes CertWatch obtain or renew certificates whenever the attribute changes. Validation has to be provided by the host @@ -104,7 +119,7 @@ to the top of your Active Admin initializer: ActiveAdmin.application.load_paths.unshift(CertWatch.active_admin_load_path) ``` -If you use the CanCan authorization adapter, you also need add the +If you use the CanCan authorization adapter, you also need to add the following rule for users that should be allowed to manage certificats: ```ruby @@ -135,6 +150,21 @@ fetch_billed_traffic_usages: Finally ensure Resque workers have been assigned to the `cert_watch` queue. +## Rake Tasks + +Add the following line to your application's `Rakefile`: + +```ruby +# Rakefile +require 'cert_watch/tasks' +``` + +To reinstall all certificates (i.e. on a new server), run: + +``` +$ bin/rake cert_watch:reinstall:all +``` + ## Active Admin View Components You can render a status tag displaying the current certificate state diff --git a/admin/cert_watch/certificates.rb b/admin/cert_watch/certificates.rb index b2baeaf..f97ce0d 100644 --- a/admin/cert_watch/certificates.rb +++ b/admin/cert_watch/certificates.rb @@ -4,7 +4,7 @@ module CertWatch ActiveAdmin.register Certificate do menu priority: 100 - actions :index, :new, :create, :show + actions :index, :new, :create, :show, :edit, :update config.batch_actions = false @@ -29,10 +29,14 @@ module CertWatch filter :domain filter :last_renewed_at + filter :provider, as: :select, collection: Certificate::PROVIDERS form do |f| f.inputs do f.input :domain + f.input :public_key + f.input :private_key + f.input :chain end f.actions end @@ -84,12 +88,17 @@ module CertWatch row :last_renewal_failed_at row :last_installed_at row :last_install_failed_at + row :public_key end end + before_create do |certificate| + certificate.provider = 'custom' + end + controller do def permitted_params - params.permit(cert_watch_certificate: [:domain]) + params.permit(certificate: [:domain, :public_key, :private_key, :chain]) end end end diff --git a/app/jobs/cert_watch/install_certificate_job.rb b/app/jobs/cert_watch/install_certificate_job.rb index 9d1f810..5f1dd3d 100644 --- a/app/jobs/cert_watch/install_certificate_job.rb +++ b/app/jobs/cert_watch/install_certificate_job.rb @@ -5,8 +5,14 @@ class InstallCertificateJob @queue = :cert_watch def self.perform_with_result(certificate, _options = {}) - CertWatch.installer.install(certificate.domain) + CertWatch.installer.install(domain: certificate.domain, + provider: certificate.provider, + public_key: certificate.public_key, + private_key: certificate.private_key, + chain: certificate.chain) + certificate.last_installed_at = Time.now + :ok rescue InstallError certificate.last_install_failed_at = Time.now diff --git a/app/jobs/cert_watch/renew_certificate_job.rb b/app/jobs/cert_watch/renew_certificate_job.rb index a56e6d4..aee550f 100644 --- a/app/jobs/cert_watch/renew_certificate_job.rb +++ b/app/jobs/cert_watch/renew_certificate_job.rb @@ -5,8 +5,11 @@ class RenewCertificateJob @queue = :cert_watch def self.perform_with_result(certificate, _options = {}) - CertWatch.client.renew(certificate.domain) + result = CertWatch.client.renew(certificate.domain) + + certificate.attributes = result.slice(:public_key, :private_key, :chain) certificate.last_renewed_at = Time.now + :ok rescue RenewError certificate.last_renewal_failed_at = Time.now diff --git a/app/jobs/cert_watch/renew_expiring_certificates_job.rb b/app/jobs/cert_watch/renew_expiring_certificates_job.rb index 83ef114..0ed628f 100644 --- a/app/jobs/cert_watch/renew_expiring_certificates_job.rb +++ b/app/jobs/cert_watch/renew_expiring_certificates_job.rb @@ -4,6 +4,7 @@ class RenewExpiringCertificatesJob def self.perform Certificate + .auto_renewable .installed .expiring .limit(CertWatch.config.renewal_batch_size) diff --git a/app/models/cert_watch/certificate.rb b/app/models/cert_watch/certificate.rb index 69d5a2d..f3b8a71 100644 --- a/app/models/cert_watch/certificate.rb +++ b/app/models/cert_watch/certificate.rb @@ -1,5 +1,9 @@ module CertWatch class Certificate < ActiveRecord::Base + PROVIDERS = %w(certbot custom).freeze + + validates :provider, inclusion: PROVIDERS + state_machine initial: 'not_installed' do extend StateMachineJob::Macro @@ -12,6 +16,7 @@ class Certificate < ActiveRecord::Base end event :install do + transition 'not_installed' => 'installing' transition 'installed' => 'installing' transition 'installing_failed' => 'installing' end @@ -38,5 +43,30 @@ class Certificate < ActiveRecord::Base scope(:failed, -> { where(state: %w(renewing_failed installing_failed)) }) scope(:expiring, -> { where('last_renewed_at < ?', CertWatch.config.renewal_interval.ago) }) scope(:abandoned, -> { where(state: 'abandoned') }) + + scope(:auto_renewable, -> { where.not(provider: 'custom') }) + scope(:custom, -> { where(provider: 'custom') }) + + def can_renew? + auto_renewable? && super + end + + def can_install? + complete? && super + end + + def auto_renewable? + !custom? + end + + def custom? + provider == 'custom' + end + + def complete? + public_key.present? && + private_key.present? && + chain.present? + end end end diff --git a/config/locales/de.yml b/config/locales/de.yml index 44d2f13..973193b 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -39,6 +39,10 @@ de: last_renewal_failed_at: "Erneuerung fehlgeschlagen" last_installed_at: "Zuletzt installiert" last_install_failed_at: "Installation fehlgeschlagen" + provider: "Typ" + public_key: "Zertifikat" + private_key: "Privater Schlüssel" + chain: "Zertifikatskette" active_admin: scopes: installed: "Installiert" diff --git a/config/locales/en.yml b/config/locales/en.yml index f326c27..138398f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -39,6 +39,10 @@ en: last_renewal_failed_at: "Last failed renewal" last_installed_at: "Last installed at" last_install_failed_at: "Last failed install" + provider: "Type" + public_key: "Certificate" + private_key: "Private Key" + chain: "Intermediate Certificates" active_admin: scopes: installed: "Installed" diff --git a/db/migrate/20171023122819_add_key_fields_to_certificates.rb b/db/migrate/20171023122819_add_key_fields_to_certificates.rb new file mode 100644 index 0000000..67c9e59 --- /dev/null +++ b/db/migrate/20171023122819_add_key_fields_to_certificates.rb @@ -0,0 +1,7 @@ +class AddKeyFieldsToCertificates < ActiveRecord::Migration + def change + add_column :cert_watch_certificates, :public_key, :text + add_column :cert_watch_certificates, :private_key, :text + add_column :cert_watch_certificates, :chain, :text + end +end diff --git a/db/migrate/20171024073152_add_provider_to_certificates.rb b/db/migrate/20171024073152_add_provider_to_certificates.rb new file mode 100644 index 0000000..46f7ed2 --- /dev/null +++ b/db/migrate/20171024073152_add_provider_to_certificates.rb @@ -0,0 +1,5 @@ +class AddProviderToCertificates < ActiveRecord::Migration + def change + add_column :cert_watch_certificates, :provider, :string, default: 'certbot', null: false + end +end diff --git a/lib/cert_watch.rb b/lib/cert_watch.rb index b913108..087e1e4 100644 --- a/lib/cert_watch.rb +++ b/lib/cert_watch.rb @@ -11,11 +11,14 @@ def self.setup yield @config if block_given? self.client = CertbotClient.new(executable: config.certbot_executable, - port: config.certbot_port) - - self.installer = PemDirectoryInstaller.new(pem_directory: config.pem_directory, - input_directory: config.certbot_output_directory, - reload_command: config.server_reload_command) + port: config.certbot_port, + output_directory: config.certbot_output_directory) + + self.installer = + PemDirectoryInstaller + .new(pem_directory: config.pem_directory, + provider_directory_mapping: config.provider_install_directory_mapping, + reload_command: config.server_reload_command) end mattr_accessor :client diff --git a/lib/cert_watch/certbot_client.rb b/lib/cert_watch/certbot_client.rb index 39b500b..eb33a14 100644 --- a/lib/cert_watch/certbot_client.rb +++ b/lib/cert_watch/certbot_client.rb @@ -3,6 +3,7 @@ class CertbotClient < Client def initialize(options) @executable = options.fetch(:executable) @port = options.fetch(:port) + @output_directory = options.fetch(:output_directory) @shell = options.fetch(:shell, Shell) end @@ -13,10 +14,20 @@ def renew(domain) end @shell.sudo(renew_command(domain)) + + read_outputs(domain) rescue Shell::CommandFailed => e fail(RenewError, e.message) end + def read_outputs(domain) + { + public_key: read_output(domain, 'cert.pem'), + private_key: read_output(domain, 'privkey.pem'), + chain: read_output(domain, 'chain.pem') + } + end + private def renew_command(domain) @@ -28,5 +39,9 @@ def flags '--agree-tos --renew-by-default ' \ "--standalone --standalone-supported-challenges http-01 --http-01-port #{@port}" end + + def read_output(domain, file_name) + @shell.sudo_read(File.join(@output_directory, domain, file_name)) + end end end diff --git a/lib/cert_watch/client.rb b/lib/cert_watch/client.rb index 2b22bb2..4594015 100644 --- a/lib/cert_watch/client.rb +++ b/lib/cert_watch/client.rb @@ -3,5 +3,9 @@ class Client def renew(_domain) fail(NotImplementedError) end + + def read_outputs(_domain) + fail(NotImplementedError) + end end end diff --git a/lib/cert_watch/configuration.rb b/lib/cert_watch/configuration.rb index 38e2a19..cbafd5a 100644 --- a/lib/cert_watch/configuration.rb +++ b/lib/cert_watch/configuration.rb @@ -19,6 +19,9 @@ class Configuration # Directory the web server reads pem files from attr_accessor :pem_directory + # Directory the web server reads pem files from + attr_accessor :provider_install_directory_mapping + # Command to make server reload pem files attr_accessor :server_reload_command @@ -31,6 +34,7 @@ def initialize @certbot_output_directory = '/etc/letsencrypt/live' @pem_directory = '/etc/haproxy/ssl/' + @provider_install_directory_mapping = {} @server_reload_command = '/etc/init.d/haproxy reload' end end diff --git a/lib/cert_watch/domain_owner.rb b/lib/cert_watch/domain_owner.rb index 0c0bdee..5ac19d7 100644 --- a/lib/cert_watch/domain_owner.rb +++ b/lib/cert_watch/domain_owner.rb @@ -13,7 +13,11 @@ def self.define(options) new_value = self[attribute] Certificate.find_by(domain: previous_value).try(:abandon) - Certificate.find_or_create_by(domain: new_value).renew if new_value.present? + + if new_value.present? + certificate = Certificate.find_or_create_by(domain: new_value) + certificate.renew! if certificate.auto_renewable? + end end end end diff --git a/lib/cert_watch/installer.rb b/lib/cert_watch/installer.rb index bb24785..2e76377 100644 --- a/lib/cert_watch/installer.rb +++ b/lib/cert_watch/installer.rb @@ -1,5 +1,5 @@ module CertWatch - class PemDirectoryInstaller < Installer + class Installer def install(_domain) fail(NotImplementedError) end diff --git a/lib/cert_watch/pem_directory_installer.rb b/lib/cert_watch/pem_directory_installer.rb index 7e68e59..68bbbac 100644 --- a/lib/cert_watch/pem_directory_installer.rb +++ b/lib/cert_watch/pem_directory_installer.rb @@ -1,12 +1,15 @@ module CertWatch - class PemDirectoryInstaller + class PemDirectoryInstaller < Installer def initialize(options) @pem_directory = options.fetch(:pem_directory) - @input_directory = options.fetch(:input_directory) + @provider_directory_mapping = + options + .fetch(:provider_directory_mapping, {}) + .with_indifferent_access @reload_command = options[:reload_command] end - def install(domain) + def install(provider:, domain:, public_key:, private_key:, chain:) if Rails.env.development? Rails.logger.info("[CertWatch] Skipping certificate install for #{domain} in development.") return @@ -14,31 +17,20 @@ def install(domain) Sanitize.check_domain!(domain) - check_inputs_exist(domain) - write_pem_file(domain) + write_pem_file(provider, domain, [public_key, chain, private_key].join) perform_reload_command end private - def check_inputs_exist(domain) - Shell.sudo("ls #{input_files(domain)}") - rescue Shell::CommandFailed - fail(InstallError, "Input files '#{input_files(domain)}' do not exist.") + def write_pem_file(provider, domain, contents) + sudo("echo -n '#{contents}' > #{pem_file(provider, domain)}") end - def write_pem_file(domain) - sudo("cat #{input_files(domain)} > #{pem_file(domain)}") - end - - def input_files(domain) - ['fullchain.pem', 'privkey.pem'].map do |file_name| - File.join(@input_directory, domain, file_name) - end.join(' ') - end - - def pem_file(domain) - File.join(@pem_directory, "#{domain}.pem") + def pem_file(provider, domain) + File.join(*[@pem_directory, + @provider_directory_mapping[provider], + "#{domain}.pem"].compact) end def perform_reload_command diff --git a/lib/cert_watch/shell.rb b/lib/cert_watch/shell.rb index 9bbf91b..89d6f75 100644 --- a/lib/cert_watch/shell.rb +++ b/lib/cert_watch/shell.rb @@ -4,10 +4,15 @@ module Shell class CommandFailed < Error; end + def sudo_read(file_name) + sudo("cat #{file_name}") + end + def sudo(command) output, input = IO.pipe + env = 'LANG=en ' prefix = !Rails.env.test? ? 'sudo ' : '' - full_command = [prefix, command].join + full_command = [env, prefix, command].join result = system(full_command, [:out, :err] => input) input.close @@ -15,6 +20,8 @@ def sudo(command) unless result fail(CommandFailed, "Command '#{full_command}' failed with output:\n\n#{output.read}\n") end + + output.read end end end diff --git a/lib/cert_watch/tasks.rb b/lib/cert_watch/tasks.rb new file mode 100644 index 0000000..d3855ea --- /dev/null +++ b/lib/cert_watch/tasks.rb @@ -0,0 +1,26 @@ +require 'rake' + +module CertWatch + module Tasks + extend Rake::DSL + + namespace :cert_watch do + namespace :reinstall do + desc 'Rewrite certificate files from database contents.' + task :all do + Certificate.installed.each(&:install) + end + end + + namespace :import do + desc 'Read certbot outputs for all certificates and store in database.' + task :certbot do + Certificate.auto_renewable.installed.each do |certificate| + result = CertWatch.client.read_outputs(certificate.domain) + certificate.update!(result.slice(:public_key, :private_key, :chain)) + end + end + end + end + end +end diff --git a/spec/cert_watch/certbot_client_spec.rb b/spec/cert_watch/certbot_client_spec.rb index 6845363..4afe089 100644 --- a/spec/cert_watch/certbot_client_spec.rb +++ b/spec/cert_watch/certbot_client_spec.rb @@ -6,9 +6,24 @@ module CertWatch instance_double('Shell', sudo: nil) end + before do + allow(shell).to receive(:sudo_read) + .with('out/some.example.com/cert.pem') + .and_return("PUBLIC KEY\n") + + allow(shell).to receive(:sudo_read) + .with('out/some.example.com/privkey.pem') + .and_return("PRIVATE KEY\n") + + allow(shell).to receive(:sudo_read) + .with('out/some.example.com/chain.pem') + .and_return("CHAIN\n") + end + let(:client) do CertbotClient.new(executable: '/usr/bin/certbot', port: 99, + output_directory: 'out', shell: shell) end @@ -49,5 +64,13 @@ module CertWatch client.renew('some.example.com') end.to raise_error(RenewError) end + + it 'returns hash with contents of pem files' do + result = client.renew('some.example.com') + + expect(result).to eql(public_key: "PUBLIC KEY\n", + private_key: "PRIVATE KEY\n", + chain: "CHAIN\n") + end end end diff --git a/spec/cert_watch/domain_owner_spec.rb b/spec/cert_watch/domain_owner_spec.rb index a2b4d9b..3fe906a 100644 --- a/spec/cert_watch/domain_owner_spec.rb +++ b/spec/cert_watch/domain_owner_spec.rb @@ -20,6 +20,27 @@ module CertWatch expect(certificate.state).to eq('renewing') end + it 'does not create certificate of custom certificate exists' do + create(:certificate, + state: 'installed', + provider: 'custom', + domain: 'custom.example.com') + + expect do + test_domain_owner_model.create!(cname: 'custom.example.com') + end.not_to change { Certificate.count } + end + + it 'does not renew existing custom certificate' do + certificate = create(:certificate, + state: 'installed', + provider: 'custom', + domain: 'custom.example.com') + test_domain_owner_model.create!(cname: 'custom.example.com') + + expect(certificate.reload.state).to eq('installed') + end + it 'renews certificate on update' do domain_owner = test_domain_owner_model.create!(cname: 'old.example.com') diff --git a/spec/cert_watch/pem_directory_installer_spec.rb b/spec/cert_watch/pem_directory_installer_spec.rb index fb6b9d8..ae3417d 100644 --- a/spec/cert_watch/pem_directory_installer_spec.rb +++ b/spec/cert_watch/pem_directory_installer_spec.rb @@ -3,72 +3,79 @@ module CertWatch RSpec.describe PemDirectoryInstaller, fixture_files: true do before do - Fixtures.file('live/some.example.com/fullchain.pem', "FULL CHAIN\n") - Fixtures.file('live/some.example.com/privkey.pem', "PRIVATE KEY\n") - Fixtures.directory('ssl') + Fixtures.directory('ssl/letsencrypt') end it 'concatenates full chain and private key files' do - installer = PemDirectoryInstaller.new(input_directory: 'live', - pem_directory: 'ssl', + installer = PemDirectoryInstaller.new(pem_directory: 'ssl', reload_command: 'touch reload.txt') - installer.install('some.example.com') + installer.install(domain: 'some.example.com', + provider: 'custom', + public_key: "PUBLIC KEY\n", + chain: "CHAIN\n", + private_key: "PRIVATE KEY\n") - expect(File.read('ssl/some.example.com.pem')).to eq("FULL CHAIN\nPRIVATE KEY\n") + expect(File.read('ssl/some.example.com.pem')).to eq("PUBLIC KEY\nCHAIN\nPRIVATE KEY\n") + end + + it 'supports writing into provider specific directories' do + installer = PemDirectoryInstaller.new(pem_directory: 'ssl', + provider_directory_mapping: {certbot: 'letsencrypt'}, + reload_command: 'touch reload.txt') + installer.install(domain: 'some.example.com', + provider: 'certbot', + public_key: "PUBLIC KEY\n", + chain: "CHAIN\n", + private_key: "PRIVATE KEY\n") + + expect(File.read('ssl/letsencrypt/some.example.com.pem')) + .to eq("PUBLIC KEY\nCHAIN\nPRIVATE KEY\n") end it 'invokes reload command' do - installer = PemDirectoryInstaller.new(input_directory: 'live', - pem_directory: 'ssl', + installer = PemDirectoryInstaller.new(pem_directory: 'ssl', reload_command: 'touch reload.txt') - installer.install('some.example.com') + installer.install(domain: 'some.example.com', + provider: 'custom', + public_key: "PUBLIC KEY\n", + chain: "CHAIN\n", + private_key: "PRIVATE KEY\n") expect(File.exist?('reload.txt')).to eq(true) end it 'fails with InstallError if reload command fails' do - installer = PemDirectoryInstaller.new(input_directory: 'live', - pem_directory: 'ssl', + installer = PemDirectoryInstaller.new(pem_directory: 'ssl', reload_command: './not_there') expect do - installer.install('some.example.com') - end.to raise_error(InstallError) - end - - it 'fails with InstallError if input files not found' do - installer = PemDirectoryInstaller.new(input_directory: 'live', - pem_directory: 'ssl') - expect do - installer.install('not-there.example.com') + installer.install(domain: 'some.example.com', + provider: 'custom', + public_key: "PUBLIC KEY\n", + chain: "CHAIN\n", + private_key: "PRIVATE KEY\n") end.to raise_error(InstallError) end - it 'does not create output file if input files not found' do - installer = PemDirectoryInstaller.new(input_directory: 'live', - pem_directory: 'ssl') - - begin - installer.install('not-there.example.com') - rescue InstallError - end - - expect(File.exist?('ssl/not-there.example.com.pem')).to eq(false) - end - it 'fails with InstallError if output directory does not exist' do - installer = PemDirectoryInstaller.new(input_directory: 'live', - pem_directory: 'not-there') + installer = PemDirectoryInstaller.new(pem_directory: 'not-there') expect do - installer.install('some.example.com') + installer.install(domain: 'some.example.com', + provider: 'custom', + public_key: "PUBLIC KEY\n", + chain: "CHAIN\n", + private_key: "PRIVATE KEY\n") end.to raise_error(InstallError) end it 'fails if domain contains forbidden characters' do - installer = PemDirectoryInstaller.new(input_directory: 'live', - pem_directory: 'ssl') + installer = PemDirectoryInstaller.new(pem_directory: 'ssl') expect do - installer.install('some.*example ".com') + installer.install(domain: 'some.*example ".com', + provider: 'custom', + public_key: "PUBLIC KEY\n", + chain: "CHAIN\n", + private_key: "PRIVATE KEY\n") end.to raise_error(Sanitize::ForbiddenCharacters) end end diff --git a/spec/cert_watch/shell_spec.rb b/spec/cert_watch/shell_spec.rb index 7c47992..72d6eea 100644 --- a/spec/cert_watch/shell_spec.rb +++ b/spec/cert_watch/shell_spec.rb @@ -9,11 +9,35 @@ module CertWatch expect(File.read('foo')).to eq("test\n") end + it 'returns output as strigng' do + Fixtures.file('foo', "CONTENTS\n") + + result = Shell.sudo('cat foo') + + expect(result).to eq("CONTENTS\n") + end + it 'raises CommandFailed with output if command fails' do expect do Shell.sudo('LANG=en touch not/there') end.to raise_error(Shell::CommandFailed, /cannot touch/) end end + + describe '.sudo_read' do + it 'returns file content' do + Fixtures.file('foo', "CONTENTS\n") + + result = Shell.sudo_read('foo') + + expect(result).to eq("CONTENTS\n") + end + + it 'raises CommandFailed if file is missing' do + expect do + Shell.sudo_read('not/there') + end.to raise_error(Shell::CommandFailed, /No such file/) + end + end end end diff --git a/spec/cert_watch/tasks_spec.rb b/spec/cert_watch/tasks_spec.rb new file mode 100644 index 0000000..9dc51c0 --- /dev/null +++ b/spec/cert_watch/tasks_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' +require 'cert_watch/tasks' + +require 'support/helpers/doubles' +require 'support/helpers/inline_resque' + +RSpec.describe 'tasks', fixture_files: true, inline_resque: true do + describe 'cert_watch:reinstall:all' do + it 'reinstalls installed certificates' do + create(:certificate, state: 'installed', domain: 'some.example.com') + + Rake.application['cert_watch:reinstall:all'].invoke + + expect(CertWatch.installer).to have_received(:install) + .with(hash_including(domain: 'some.example.com')) + end + end + + describe 'cert_watch:import:certbot' do + it 'imports installed auto renewable certificates' do + certificate = create(:certificate, :auto_renewable, state: 'installed') + + Rake.application['cert_watch:import:certbot'].invoke + certificate.reload + + expect(certificate.public_key).to be_present + expect(certificate.private_key).to be_present + expect(certificate.chain).to be_present + end + end +end diff --git a/spec/controllers/admin/cert_watch/certificates_controller_spec.rb b/spec/controllers/admin/cert_watch/certificates_controller_spec.rb new file mode 100644 index 0000000..1f1e8ac --- /dev/null +++ b/spec/controllers/admin/cert_watch/certificates_controller_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +module Admin + RSpec.describe CertWatchCertificatesController, type: :controller do + describe '#create' do + it 'creates custom certificate' do + post(:create, certificate: { + domain: 'test.example.com', + public_key: 'PUBLIC', + private_key: 'PRIVATE', + chain: 'CHAIN' + }) + + expect(CertWatch::Certificate.custom.where(domain: 'test.example.com')).to exist + end + end + end +end diff --git a/spec/factories/certificates.rb b/spec/factories/certificates.rb index af772d9..061a736 100644 --- a/spec/factories/certificates.rb +++ b/spec/factories/certificates.rb @@ -1,6 +1,23 @@ + +require 'support/helpers/doubles' + FactoryGirl.define do factory(:certificate, class: CertWatch::Certificate) do domain 'some.example.com' state 'not_installed' + + trait :complete do + public_key Doubles::PUBLIC_KEY + private_key Doubles::PRIVATE_KEY + chain Doubles::CHAIN + end + + trait :auto_renewable do + provider 'certbot' + end + + trait :custom do + provider 'custom' + end end end diff --git a/spec/internal/app/controllers/application_controller.rb b/spec/internal/app/controllers/application_controller.rb new file mode 100644 index 0000000..09705d1 --- /dev/null +++ b/spec/internal/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/spec/internal/config/initializers/active_admin.rb b/spec/internal/config/initializers/active_admin.rb new file mode 100644 index 0000000..ecfd95f --- /dev/null +++ b/spec/internal/config/initializers/active_admin.rb @@ -0,0 +1 @@ +ActiveAdmin.application.load_paths.unshift(CertWatch.active_admin_load_path) diff --git a/spec/internal/config/routes.rb b/spec/internal/config/routes.rb index 14560a4..36d2dac 100644 --- a/spec/internal/config/routes.rb +++ b/spec/internal/config/routes.rb @@ -1,3 +1,3 @@ Rails.application.routes.draw do - # + ActiveAdmin.routes(self) end diff --git a/spec/models/cert_watch/certificate_spec.rb b/spec/models/cert_watch/certificate_spec.rb index b44882c..eb61062 100644 --- a/spec/models/cert_watch/certificate_spec.rb +++ b/spec/models/cert_watch/certificate_spec.rb @@ -5,6 +5,74 @@ module CertWatch RSpec.describe Certificate, inline_resque: true do + describe '.auto_renewable' do + it 'includes auto renewable certificates' do + certificate = create(:certificate, :auto_renewable) + + expect(Certificate.auto_renewable).to include(certificate) + end + + it 'does not include custom certificates' do + certificate = create(:certificate, :custom) + + expect(Certificate.auto_renewable).not_to include(certificate) + end + end + + describe '.custom' do + it 'includes custom certificates' do + certificate = create(:certificate, :custom) + + expect(Certificate.custom).to include(certificate) + end + + it 'does not include auto renewable certificates' do + certificate = create(:certificate, :auto_renewable) + + expect(Certificate.custom).not_to include(certificate) + end + end + + describe 'can_renew?' do + it 'returns true for auto renewable certificate' do + certificate = create(:certificate, :auto_renewable) + + expect(certificate.can_renew?).to eq(true) + end + + it 'returns false for custom certificate' do + certificate = create(:certificate, :custom) + + expect(certificate.can_renew?).to eq(false) + end + end + + describe 'can_install?' do + it 'returns true for complete installed auto renewable certificate' do + certificate = create(:certificate, :auto_renewable, :complete, state: 'installed') + + expect(certificate.can_install?).to eq(true) + end + + it 'returns false for not_installed auto renewable certificate' do + certificate = create(:certificate, :auto_renewable, state: 'not_installed') + + expect(certificate.can_install?).to eq(false) + end + + it 'returns false for complete custom certificate' do + certificate = create(:certificate, :custom, :complete) + + expect(certificate.can_install?).to eq(true) + end + + it 'returns false for incomplete custom certificate' do + certificate = create(:certificate, :custom) + + expect(certificate.can_install?).to eq(false) + end + end + describe '#renew' do it 'makes client renew certificate for domain' do certificate = create(:certificate, domain: 'my.example.com') @@ -15,11 +83,18 @@ module CertWatch end it 'installs certificate' do - certificate = create(:certificate, domain: 'my.example.com') + certificate = create(:certificate, + provider: 'certbot', + domain: 'my.example.com') certificate.renew! - expect(CertWatch.installer).to have_received(:install).with('my.example.com') + expect(CertWatch.installer).to have_received(:install) + .with(domain: 'my.example.com', + provider: 'certbot', + public_key: Doubles::PUBLIC_KEY, + private_key: Doubles::PRIVATE_KEY, + chain: Doubles::CHAIN) end it 'sets state to installed' do @@ -30,6 +105,17 @@ module CertWatch expect(certificate.reload.state).to eq('installed') end + it 'stores keys' do + certificate = create(:certificate, + domain: 'my.example.com') + + certificate.renew! + + expect(certificate.reload.public_key).to be_present + expect(certificate.reload.private_key).to be_present + expect(certificate.reload.chain).to be_present + end + it 'updates last_renewed_at attribute' do certificate = create(:certificate, domain: 'my.example.com', @@ -98,5 +184,67 @@ module CertWatch end end end + + describe '#install' do + it 'installs certificate' do + certificate = create(:certificate, + :complete, + provider: 'custom', + domain: 'my.example.com') + + certificate.install! + + expect(CertWatch.installer).to have_received(:install) + .with(domain: 'my.example.com', + provider: 'custom', + public_key: Doubles::PUBLIC_KEY, + private_key: Doubles::PRIVATE_KEY, + chain: Doubles::CHAIN) + end + + it 'sets state to installed' do + certificate = create(:certificate, :complete, domain: 'my.example.com') + + certificate.install! + + expect(certificate.reload.state).to eq('installed') + end + + it 'updates last_installed_at attribute' do + certificate = create(:certificate, + :complete, + domain: 'my.example.com', + last_installed_at: 1.month.ago) + + certificate.install! + + expect(certificate.reload.last_installed_at).to eq(Time.now) + end + + context 'when install results in error' do + before do + CertWatch.installer = Doubles.failing_installer + end + + it 'sets state to installing_failed' do + certificate = create(:certificate, :complete, domain: 'my.example.com') + + certificate.install! + + expect(certificate.reload.state).to eq('installing_failed') + end + + it 'updates last_install_failed_at attribute' do + certificate = create(:certificate, + :complete, + domain: 'my.example.com', + last_install_failed_at: 1.month.ago) + + certificate.install! + + expect(certificate.reload.last_install_failed_at).to eq(Time.now) + end + end + end end end diff --git a/spec/support/config/cert_watch.rb b/spec/support/config/cert_watch.rb index 8b84dc1..ea742a0 100644 --- a/spec/support/config/cert_watch.rb +++ b/spec/support/config/cert_watch.rb @@ -1,3 +1,5 @@ +require 'support/helpers/doubles' + RSpec.configure do |config| config.before do CertWatch.setup diff --git a/spec/support/helpers/doubles.rb b/spec/support/helpers/doubles.rb index e1837bf..3d0bf2c 100644 --- a/spec/support/helpers/doubles.rb +++ b/spec/support/helpers/doubles.rb @@ -2,9 +2,19 @@ module Doubles extend RSpec::Mocks::ExampleMethods extend self + PUBLIC_KEY = "PUBLIC KEY\n".freeze + PRIVATE_KEY = "PRIVATE KEY\n".freeze + CHAIN = "CHAIN\n".freeze + def client instance_double('CertWatch::Client').tap do |double| - allow(double).to receive(:renew).and_return(:ok) + allow(double).to receive(:renew).and_return(public_key: PUBLIC_KEY, + private_key: PRIVATE_KEY, + chain: CHAIN) + + allow(double).to receive(:read_outputs).and_return(public_key: PUBLIC_KEY, + private_key: PRIVATE_KEY, + chain: CHAIN) end end