diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bfe334ef..c2b85a279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +31.03.2021 +* Implemented child to parent syncronisation (csync) for cdnskeys [#658](https://github.com/internetee/registry/issues/658) + 29.03.2021 * Full EPP functionality to REPP API [#1756](https://github.com/internetee/registry/issues/1756) diff --git a/Gemfile b/Gemfile index be4a9756b..75c3c048e 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,8 @@ gem 'active_interaction', '~> 3.8' gem 'apipie-rails', '~> 0.5.18' gem 'bootsnap', '>= 1.1.0', require: false gem 'iso8601', '0.12.1' # for dates and times +gem 'mime-types-data' +gem 'mimemagic', '0.3.10' gem 'rails', '~> 6.0' gem 'rest-client' gem 'uglifier' @@ -39,6 +41,7 @@ gem 'devise', '~> 4.7' # registry specfic gem 'data_migrate', '~> 6.1' +gem 'dnsruby', '~> 1.61' gem 'isikukood' # for EE-id validation gem 'simpleidn', '0.1.1' # For punycode gem 'money-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 563c39fdc..cc4238920 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -76,60 +76,60 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.0.3.5) - actionpack (= 6.0.3.5) + actioncable (6.0.3.6) + actionpack (= 6.0.3.6) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.3.5) - actionpack (= 6.0.3.5) - activejob (= 6.0.3.5) - activerecord (= 6.0.3.5) - activestorage (= 6.0.3.5) - activesupport (= 6.0.3.5) + actionmailbox (6.0.3.6) + actionpack (= 6.0.3.6) + activejob (= 6.0.3.6) + activerecord (= 6.0.3.6) + activestorage (= 6.0.3.6) + activesupport (= 6.0.3.6) mail (>= 2.7.1) - actionmailer (6.0.3.5) - actionpack (= 6.0.3.5) - actionview (= 6.0.3.5) - activejob (= 6.0.3.5) + actionmailer (6.0.3.6) + actionpack (= 6.0.3.6) + actionview (= 6.0.3.6) + activejob (= 6.0.3.6) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.3.5) - actionview (= 6.0.3.5) - activesupport (= 6.0.3.5) + actionpack (6.0.3.6) + actionview (= 6.0.3.6) + activesupport (= 6.0.3.6) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.3.5) - actionpack (= 6.0.3.5) - activerecord (= 6.0.3.5) - activestorage (= 6.0.3.5) - activesupport (= 6.0.3.5) + actiontext (6.0.3.6) + actionpack (= 6.0.3.6) + activerecord (= 6.0.3.6) + activestorage (= 6.0.3.6) + activesupport (= 6.0.3.6) nokogiri (>= 1.8.5) - actionview (6.0.3.5) - activesupport (= 6.0.3.5) + actionview (6.0.3.6) + activesupport (= 6.0.3.6) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_interaction (3.8.3) activemodel (>= 4, < 7) - activejob (6.0.3.5) - activesupport (= 6.0.3.5) + activejob (6.0.3.6) + activesupport (= 6.0.3.6) globalid (>= 0.3.6) - activemodel (6.0.3.5) - activesupport (= 6.0.3.5) - activerecord (6.0.3.5) - activemodel (= 6.0.3.5) - activesupport (= 6.0.3.5) + activemodel (6.0.3.6) + activesupport (= 6.0.3.6) + activerecord (6.0.3.6) + activemodel (= 6.0.3.6) + activesupport (= 6.0.3.6) activerecord-import (1.0.8) activerecord (>= 3.2) - activestorage (6.0.3.5) - actionpack (= 6.0.3.5) - activejob (= 6.0.3.5) - activerecord (= 6.0.3.5) - marcel (~> 0.3.1) - activesupport (6.0.3.5) + activestorage (6.0.3.6) + actionpack (= 6.0.3.6) + activejob (= 6.0.3.6) + activerecord (= 6.0.3.6) + marcel (~> 1.0.0) + activesupport (6.0.3.6) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) @@ -215,6 +215,8 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + dnsruby (1.61.5) + simpleidn (~> 0.1) docile (1.3.5) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -277,14 +279,15 @@ GEM nokogiri (>= 1.5.9) mail (2.7.1) mini_mime (>= 0.1.1) - marcel (0.3.3) - mimemagic (~> 0.3.2) + marcel (1.0.0) method_source (0.8.2) mime-types (3.3.1) mime-types-data (~> 3.2015) mime-types-data (3.2021.0225) - mimemagic (0.3.5) - mini_mime (1.0.2) + mimemagic (0.3.10) + nokogiri (~> 1) + rake + mini_mime (1.0.3) mini_portile2 (2.4.0) minitest (5.14.4) monetize (1.9.4) @@ -350,29 +353,29 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.3.5) - actioncable (= 6.0.3.5) - actionmailbox (= 6.0.3.5) - actionmailer (= 6.0.3.5) - actionpack (= 6.0.3.5) - actiontext (= 6.0.3.5) - actionview (= 6.0.3.5) - activejob (= 6.0.3.5) - activemodel (= 6.0.3.5) - activerecord (= 6.0.3.5) - activestorage (= 6.0.3.5) - activesupport (= 6.0.3.5) + rails (6.0.3.6) + actioncable (= 6.0.3.6) + actionmailbox (= 6.0.3.6) + actionmailer (= 6.0.3.6) + actionpack (= 6.0.3.6) + actiontext (= 6.0.3.6) + actionview (= 6.0.3.6) + activejob (= 6.0.3.6) + activemodel (= 6.0.3.6) + activerecord (= 6.0.3.6) + activestorage (= 6.0.3.6) + activesupport (= 6.0.3.6) bundler (>= 1.3.0) - railties (= 6.0.3.5) + railties (= 6.0.3.6) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - railties (6.0.3.5) - actionpack (= 6.0.3.5) - activesupport (= 6.0.3.5) + railties (6.0.3.6) + actionpack (= 6.0.3.6) + activesupport (= 6.0.3.6) method_source rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) @@ -519,6 +522,7 @@ DEPENDENCIES devise (~> 4.7) digidoc_client! directo! + dnsruby (~> 1.61) domain_name e_invoice! epp! @@ -531,6 +535,8 @@ DEPENDENCIES jquery-ui-rails (= 5.0.5) kaminari lhv! + mime-types-data + mimemagic (= 0.3.10) minitest (~> 5.14) money-rails nokogiri (~> 1.10.0) diff --git a/app/jobs/csync_job.rb b/app/jobs/csync_job.rb new file mode 100644 index 000000000..32fc1abcd --- /dev/null +++ b/app/jobs/csync_job.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +class CsyncJob < Que::Job + def run(generate: false) + @store = {} + @input_store = { secure: {}, insecure: {} } + @results = {} + @logger = Rails.env.test? ? Rails.logger : Logger.new(STDOUT) + generate ? generate_scanner_input : process_scanner_results + + @logger.info 'CsyncJob: Finished.' + end + + def qualified_for_monitoring?(domain, data) + result_types = data[:ns].map { |ns| ns[:type] }.uniq + ns_ok = redundant_data_for?(nameserver: true, input: result_types) + key_ok = redundant_data_for?(nameserver: false, input: data) + + return true if ns_ok && key_ok + + @logger.info "CsyncJob: #{domain}: Reseting state. Reason: " + + unqualification_reason(ns_ok, key_ok, result_types) + + CsyncRecord.where(domain: Domain.where(name: domain)).delete_all + + false + end + + def redundant_data_for?(nameserver: false, input:) + if nameserver + input.size == 1 && (input & %w[secure insecure]).any? + else + input[:ns].map { |ns| ns[:cdnskey] }.uniq.size == 1 + end + end + + def unqualification_reason(nss, key, result_types) + return 'no CDNSKEY / nameservers reported different CDNSKEYs' unless key + + if result_types.include? 'untrustworthy' + return 'current DNSSEC config invalid (required for rollover/delete)' + end + + "Nameserver(s) not reachable / invalid data (#{result_types.join(', ')})" unless nss + end + + def process_scanner_results + scanner_results + + @results.keys.each do |domain| + next unless qualified_for_monitoring?(domain, @results[domain]) + + CsyncRecord.by_domain_name(domain)&.record_new_scan(@results[domain][:ns].first) + end + end + + def scanner_results + scanner_line_results.each do |fetch| + domain_name = fetch[:domain] + @results[domain_name] = { ns: [] } unless @results[domain_name] + @results[domain_name][:ns] << fetch.except(:domain) + end + end + + def scanner_line_results + records = [] + File.open(ENV['cdns_scanner_output_file'], 'r').each_line do |line| + # Input type, NS host, NS IP, Domain name, Key type, Protocol, Algorithm, Public key + data = line.strip.split(' ') + if data[0] == 'secure' + type, domain, key_bit, proto, alg, pub, ns, ns_ip = data + else + type, ns, ns_ip, domain, key_bit, proto, alg, pub = data + end + cdnskey = key_bit && proto && alg && pub ? "#{key_bit} #{proto} #{alg} #{pub}" : nil + record = { domain: domain, type: type, ns: ns, ns_ip: ns_ip, flags: key_bit, proto: proto, + alg: alg, pub: pub, cdnskey: cdnskey } + records << record + end + records + end + + # From this point we're working on generating input for cdnskey-scanner + def gather_pollable_domains + @logger.info 'CsyncJob Generate: Gathering current domain(s) data' + Nameserver.select(:hostname, :domain_id).all.each do |ns| + %i[secure insecure].each do |i| + @input_store[i][ns.hostname] = [] unless @input_store[i].key? ns.hostname + end + + append_domains_to_list(ns) + end + end + + def append_domains_to_list(nameserver) + Domain.where(id: nameserver.domain_id).all.each do |domain| + @input_store[domain.dnskeys.any? ? :secure : :insecure][nameserver.hostname].push domain.name + end + end + + def generate_scanner_input + @logger.info 'CsyncJob Generate: Gathering current domain(s) data' + gather_pollable_domains + + out_file = File.new(ENV['cdns_scanner_input_file'], 'w+') + + %i[secure insecure].each do |state| + out_file.puts "[#{state}]" + create_input_lines(out_file, state) + end + + out_file.close + @logger.info 'CsyncJob Generate: Finished writing output to ' + ENV['cdns_scanner_input_file'] + end + + def create_input_lines(out_file, state) + @input_store[state].keys.each do |nameserver| + domains = @input_store[state][nameserver].join(' ') + next unless domains.length.positive? + + out_file.puts "#{nameserver} #{domains}" + end + end +end diff --git a/app/mailers/csync_mailer.rb b/app/mailers/csync_mailer.rb new file mode 100644 index 000000000..2779ff8a5 --- /dev/null +++ b/app/mailers/csync_mailer.rb @@ -0,0 +1,23 @@ +class CsyncMailer < ApplicationMailer + def dnssec_updated(domain:) + @domain = domain + emails = contact_emails(domain) + + subject = default_i18n_subject(domain_name: domain.name) + mail(to: emails, subject: subject) + end + + def dnssec_deleted(domain:) + @domain = domain + emails = contact_emails(domain) + + subject = default_i18n_subject(domain_name: domain.name) + mail(to: emails, subject: subject) + end + + private + + def contact_emails(domain) + (domain.contacts.map(&:email) << domain.registrant.email).uniq + end +end diff --git a/app/models/actions/contact_create.rb b/app/models/actions/contact_create.rb new file mode 100644 index 000000000..22fabc7b9 --- /dev/null +++ b/app/models/actions/contact_create.rb @@ -0,0 +1,81 @@ +module Actions + class ContactCreate + attr_reader :contact, :legal_document, :ident + + def initialize(contact, legal_document, ident) + @contact = contact + @legal_document = legal_document + @ident = ident + end + + def call + maybe_remove_address + maybe_attach_legal_doc + validate_ident + commit + end + + def maybe_remove_address + return if Contact.address_processing? + + contact.city = nil + contact.zip = nil + contact.street = nil + contact.state = nil + contact.country_code = nil + end + + def validate_ident + validate_ident_integrity + validate_ident_birthday + + identifier = ::Contact::Ident.new(code: ident[:ident], type: ident[:ident_type], + country_code: ident[:ident_country_code]) + + identifier.validate + contact.identifier = identifier + end + + def validate_ident_integrity + return if ident.blank? + + if ident[:ident_type].blank? + contact.add_epp_error('2003', nil, 'ident_type', + I18n.t('errors.messages.required_ident_attribute_missing')) + @error = true + elsif !%w[priv org birthday].include?(ident[:ident_type]) + contact.add_epp_error('2003', nil, 'ident_type', 'Invalid ident type') + @error = true + end + end + + def validate_ident_birthday + return if ident.blank? + return unless ident[:ident_type] != 'birthday' && ident[:ident_country_code].blank? + + contact.add_epp_error('2003', nil, 'ident_country_code', + I18n.t('errors.messages.required_ident_attribute_missing')) + @error = true + end + + def maybe_attach_legal_doc + return unless legal_document + + doc = LegalDocument.create( + documentable_type: Contact, + document_type: legal_document[:type], body: legal_document[:body] + ) + + contact.legal_documents = [doc] + contact.legal_document_id = doc.id + end + + def commit + contact.id = nil # new record + return false if @error + + contact.generate_code + contact.save + end + end +end diff --git a/app/models/actions/contact_delete.rb b/app/models/actions/contact_delete.rb new file mode 100644 index 000000000..60d3252c4 --- /dev/null +++ b/app/models/actions/contact_delete.rb @@ -0,0 +1,46 @@ +module Actions + class ContactDelete + attr_reader :contact + attr_reader :new_attributes + attr_reader :legal_document + attr_reader :ident + attr_reader :user + + def initialize(contact, legal_document = nil) + @legal_document = legal_document + @contact = contact + end + + def call + maybe_attach_legal_doc + + if contact.linked? + contact.errors.add(:domains, :exist) + return + end + + if contact.delete_prohibited? + contact.errors.add(:statuses, :delete_prohibited) + return + end + + commit + end + + def maybe_attach_legal_doc + return unless legal_document + + document = contact.legal_documents.create( + document_type: legal_document[:type], + body: legal_document[:body] + ) + + contact.legal_document_id = document.id + contact.save + end + + def commit + contact.destroy + end + end +end diff --git a/app/models/actions/contact_update.rb b/app/models/actions/contact_update.rb new file mode 100644 index 000000000..7ca7b6b04 --- /dev/null +++ b/app/models/actions/contact_update.rb @@ -0,0 +1,109 @@ +module Actions + class ContactUpdate + attr_reader :contact + attr_reader :new_attributes + attr_reader :legal_document + attr_reader :ident + attr_reader :user + + def initialize(contact, new_attributes, legal_document, ident, user) + @contact = contact + @new_attributes = new_attributes + @legal_document = legal_document + @ident = ident + @user = user + end + + def call + maybe_remove_address + maybe_update_statuses + maybe_update_ident if ident.present? + maybe_attach_legal_doc + commit + end + + def maybe_remove_address + return if Contact.address_processing? + + new_attributes.delete(:city) + new_attributes.delete(:zip) + new_attributes.delete(:street) + new_attributes.delete(:state) + new_attributes.delete(:country_code) + end + + def maybe_update_statuses + return unless Setting.client_status_editing_enabled + + new_statuses = + contact.statuses - new_attributes[:statuses_to_remove] + new_attributes[:statuses_to_add] + + new_attributes[:statuses] = new_statuses + end + + def maybe_attach_legal_doc + return unless legal_document + + document = contact.legal_documents.create( + document_type: legal_document[:type], + body: legal_document[:body] + ) + + contact.legal_document_id = document.id + end + + def maybe_update_ident + unless ident.is_a?(Hash) + contact.add_epp_error('2308', nil, nil, I18n.t('epp.contacts.errors.valid_ident')) + @error = true + return + end + + if contact.identifier.valid? + submitted_ident = ::Contact::Ident.new(code: ident[:ident], + type: ident[:ident_type], + country_code: ident[:ident_country_code]) + + if submitted_ident != contact.identifier + contact.add_epp_error('2308', nil, nil, I18n.t('epp.contacts.errors.valid_ident')) + @error = true + end + else + ident_update_attempt = ident[:ident] != contact.ident + + if ident_update_attempt + contact.add_epp_error('2308', nil, nil, I18n.t('epp.contacts.errors.ident_update')) + @error = true + end + + identifier = ::Contact::Ident.new(code: ident[:ident], + type: ident[:ident_type], + country_code: ident[:ident_country_code]) + + identifier.validate + + contact.identifier = identifier + contact.ident_updated_at ||= Time.zone.now + end + end + + def commit + return false if @error + + contact.upid = user.registrar&.id + contact.up_date = Time.zone.now + + contact.attributes = new_attributes + + email_changed = contact.will_save_change_to_email? + old_email = contact.email_was + updated = contact.save + + if updated && email_changed && contact.registrant? + ContactMailer.email_changed(contact: contact, old_email: old_email).deliver_now + end + + updated + end + end +end diff --git a/app/models/actions/domain_transfer.rb b/app/models/actions/domain_transfer.rb new file mode 100644 index 000000000..1ff9aafe9 --- /dev/null +++ b/app/models/actions/domain_transfer.rb @@ -0,0 +1,74 @@ +module Actions + class DomainTransfer + attr_reader :domain + attr_reader :transfer_code + attr_reader :legal_document + attr_reader :ident + attr_reader :user + + def initialize(domain, transfer_code, user) + @domain = domain + @transfer_code = transfer_code + @user = user + end + + def call + return unless domain_exists? + return unless valid_transfer_code? + + run_validations + + # return domain.pending_transfer if domain.pending_transfer + # attach_legal_document(::Deserializers::Xml::LegalDocument.new(frame).call) + + return if domain.errors[:epp_errors].any? + + commit + end + + def domain_exists? + return true if domain.persisted? + + domain.add_epp_error('2303', nil, nil, 'Object does not exist') + + false + end + + def run_validations + validate_registrar + validate_eligilibty + validate_not_discarded + end + + def valid_transfer_code? + return true if transfer_code == domain.transfer_code + + domain.add_epp_error('2202', nil, nil, 'Invalid authorization information') + false + end + + def validate_registrar + return unless user == domain.registrar + + domain.add_epp_error('2002', nil, nil, + I18n.t(:domain_already_belongs_to_the_querying_registrar)) + end + + def validate_eligilibty + return unless domain.non_transferable? + + domain.add_epp_error('2304', nil, nil, 'Object status prohibits operation') + end + + def validate_not_discarded + return unless domain.discarded? + + domain.add_epp_error('2106', nil, nil, 'Object is not eligible for transfer') + end + + def commit + bare_domain = Domain.find(domain.id) + ::DomainTransfer.request(bare_domain, user) + end + end +end diff --git a/app/models/concerns/csync_record/diggable.rb b/app/models/concerns/csync_record/diggable.rb new file mode 100644 index 000000000..fe5d3c072 --- /dev/null +++ b/app/models/concerns/csync_record/diggable.rb @@ -0,0 +1,45 @@ +module CsyncRecord::Diggable + extend ActiveSupport::Concern + + def valid_security_level?(post: false) + res = post ? valid_post_action? : valid_pre_action? + + log_dnssec_entry(valid: res, post: post) + res + rescue Dnsruby::NXDomain + log.info("CsyncRecord: #{domain.name}: Could not resolve (NXDomain)") + false + end + + def valid_pre_action? + case domain.dnssec_security_level + when Dnsruby::Message::SecurityLevel.SECURE + return true if %w[rollover deactivate].include?(action) + when Dnsruby::Message::SecurityLevel.INSECURE, Dnsruby::Message::SecurityLevel.BOGUS + return true if action == 'initialized' + end + + false + end + + def valid_post_action? + secure_msg = Dnsruby::Message::SecurityLevel.SECURE + security_level = domain.dnssec_security_level(stubber: dnskey) + return true if action == 'deactivate' && security_level != secure_msg + return true if %w[rollover initialized].include?(action) && security_level == secure_msg + + false + end + + def dnssec_validates? + return false unless dnskey.valid? + return true if valid_security_level? && valid_security_level?(post: true) + + false + end + + def log_dnssec_entry(valid:, post:) + log.info("#{domain.name}: #{post ? 'Post' : 'Pre'} DNSSEC validation " \ + "#{valid ? 'PASSED' : 'FAILED'} for action '#{action}'") + end +end diff --git a/app/models/csync_record.rb b/app/models/csync_record.rb new file mode 100644 index 000000000..b4893c7d1 --- /dev/null +++ b/app/models/csync_record.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +class CsyncRecord < ApplicationRecord + include CsyncRecord::Diggable + belongs_to :domain, optional: false + validates :domain, uniqueness: true + validates :cdnskey, :action, :last_scan, presence: true + validate :validate_unique_pub_key + validate :validate_csync_action + validate :validate_cdnskey_format + after_save :process_new_dnskey, if: proc { pushable? && !disable_requested? } + after_save :remove_dnskeys, if: proc { pushable? && disable_requested? } + + SCAN_CYCLES = 3 + + def record_new_scan(result) + assign_scanner_data!(result) + prefix = "CsyncRecord: #{domain.name}:" + + if save + log.info "#{prefix} Cycle done." + else + log.info "#{prefix}: not processing. Reason: #{errors.full_messages.join(' .')}" + CsyncRecord.where(domain: domain).delete_all + end + end + + def assign_scanner_data!(result) + state = result[:type] + self.last_scan = Time.zone.now + self.times_scanned = (persisted? && cdnskey != result[:cdnskey] ? 1 : times_scanned + 1) + self.cdnskey = result[:cdnskey] + self.action = initializes_dnssec?(state) ? 'initialized' : determine_csync_intention(state) + end + + def dnskey + key = Dnskey.new_from_csync(domain: domain, cdnskey: cdnskey) + log.info "DNSKEY not valid. #{key.errors.full_messages.join('. ')}." unless key.valid? + + key + end + + def destroy_all_but_last_one + domain.dnskeys.order(id: :desc).offset(1).destroy_all + end + + def process_new_dnskey + return unless dnssec_validates? + + if dnskey.save + destroy_all_but_last_one + finalize_and_notify + else + log.info "Failed to save DNSKEY. Errors: #{dnskey.errors.full_messages.join('. ')}" + end + end + + def finalize_and_notify + CsyncMailer.dnssec_updated(domain: domain).deliver_now + notify_registrar_about_csync + CsyncRecord.where(domain: domain).destroy_all + log.info "CsyncRecord: #{domain.name}: DNSKEYs updated." + end + + def pushable? + return true if domain.dnskeys.any? || times_scanned >= SCAN_CYCLES + + false + end + + def disable_requested? + ['0 3 0 AA==', '0 3 0 0'].include? cdnskey + end + + def remove_dnskeys + log.info "CsyncJob: Removing DNSKEYs for domain '#{domain.name}'" + domain.dnskeys.destroy_all + CsyncMailer.dnssec_deleted(domain: domain).deliver_now + notify_registrar_about_csync + + destroy + end + + def notify_registrar_about_csync + domain.update_whois_record + domain.registrar.notifications.create!(text: I18n.t('notifications.texts.csync', + domain: domain.name, action: action)) + end + + def validate_unique_pub_key + return false unless domain + return true if disable_requested? + return true unless domain.dnskeys.where(public_key: dnskey.public_key).any? + + errors.add(:public_key, 'already tied to this domain') + end + + def self.by_domain_name(domain_name) + domain = Domain.find_by(name: domain_name) + log.info "CsyncRecord: '#{domain_name}' not in zone. Not initializing record." unless domain + CsyncRecord.find_or_initialize_by(domain: domain) if domain + end + + def determine_csync_intention(scan_state) + return unless domain.dnskeys.any? && scan_state == 'secure' + + disable_requested? ? 'deactivate' : 'rollover' + end + + def initializes_dnssec?(scan_state) + true if domain.dnskeys.empty? && !disable_requested? && scan_state == 'insecure' + end + + def log + @log ||= Rails.env.test? ? logger : Logger.new(STDOUT) + @log + end + + def validate_csync_action + return true if %w[initialized rollover].include? action + return true if action == 'deactivate' && disable_requested? + + errors.add(:action, :invalid) + end + + def validate_cdnskey_format + return true if disable_requested? + return true if dnskey.valid? + + errors.add(:cdnskey, :invalid) + end +end diff --git a/app/models/dnskey.rb b/app/models/dnskey.rb index 6c6df6e44..58d4d9e31 100644 --- a/app/models/dnskey.rb +++ b/app/models/dnskey.rb @@ -28,7 +28,7 @@ class Dnskey < ApplicationRecord PROTOCOLS = %w(3) FLAGS = %w(0 256 257) # 256 = ZSK, 257 = KSK DS_DIGEST_TYPE = [1,2] - + RESOLVERS = ENV['dnssec_resolver_ips'].to_s.strip.split(', ').freeze self.ignored_columns = %w[legacy_domain_id] def epp_code_map @@ -122,6 +122,26 @@ class Dnskey < ApplicationRecord errors.add(:public_key, :invalid) end + def self.new_from_csync(cdnskey:, domain:) + cdnskey ||= '' # avoid strip() issues for gibberish key + + flags, proto, alg, pub = cdnskey.strip.split(' ') + Dnskey.new(domain: domain, flags: flags, protocol: proto, alg: alg, public_key: pub) + end + + def ds_rr + # Break the DNSSEC trust chain as we are not able to fake RRSIG's + Dnsruby::Dnssec.clear_trust_anchors + Dnsruby::Dnssec.clear_trusted_keys + + # Basically let's configure domain as root anchor. We can still verify + # RRSIG's / DNSKEY targeted by DS of this domain + generate_digest + generate_ds_key_tag + Dnsruby::RR.create("#{domain.name}. 3600 IN DS #{ds_key_tag} #{ds_alg} " \ + "#{ds_digest_type} #{ds_digest}") + end + class << self def int_to_hex(s) s = s.to_s(16) diff --git a/app/models/domain.rb b/app/models/domain.rb index 5f32e02e2..3deaf594b 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -58,6 +58,7 @@ class Domain < ApplicationRecord has_many :legal_documents, as: :documentable has_many :registrant_verifications, dependent: :destroy + has_one :csync_record, dependent: :destroy after_initialize do self.pending_json = {} if pending_json.blank? @@ -167,6 +168,35 @@ class Domain < ApplicationRecord validate :validate_nameserver_ips validate :statuses_uniqueness + + def security_level_resolver + resolver = Dnsruby::Resolver.new(nameserver: Dnskey::RESOLVERS) + resolver.do_validation = true + resolver.do_caching = false + resolver.dnssec = true + resolver + end + + def dnssec_security_level(stubber: nil) + Dnsruby::Dnssec.reset + resolver = security_level_resolver + Dnsruby::Recursor.clear_caches(resolver) + if Rails.env.staging? + clear_dnssec_trusted_anchors_and_keys + elsif stubber + Dnsruby::Dnssec.add_trust_anchor(stubber.ds_rr) + end + recursor = Dnsruby::Recursor.new(resolver) + recursor.dnssec = true + recursor.query(name, 'A', 'IN').security_level + end + + def clear_dnssec_trusted_anchors_and_keys + Dnsruby::Dnssec.clear_trust_anchors + Dnsruby::Dnssec.clear_trusted_keys + Dnsruby::Dnssec.add_trust_anchor(Dnsruby::RR.create(ENV['trusted_dnskey'])) + end + def statuses_uniqueness return if statuses.uniq == statuses errors.add(:statuses, :taken) diff --git a/app/models/epp/contact.rb b/app/models/epp/contact.rb index 4cd876d5f..6bd5507b9 100644 --- a/app/models/epp/contact.rb +++ b/app/models/epp/contact.rb @@ -44,6 +44,8 @@ class Epp::Contact < Contact def check_availability(codes, reg:) codes = [codes] if codes.is_a?(String) + codes = codes.map { |c| c.include?(':') ? c : "#{reg}:#{c}" } + res = [] codes.map { |c| c.include?(':') ? c : "#{reg}:#{c}" }.map { |c| c.strip.upcase }.each do |x| c = find_by_epp_code(x) diff --git a/app/models/nameserver.rb b/app/models/nameserver.rb index 85e64ce41..fff757b95 100644 --- a/app/models/nameserver.rb +++ b/app/models/nameserver.rb @@ -58,8 +58,8 @@ class Nameserver < ApplicationRecord end def hostname=(hostname) - self[:hostname] = SimpleIDN.to_unicode(hostname) - self[:hostname_puny] = SimpleIDN.to_ascii(hostname) + self[:hostname] = SimpleIDN.to_unicode(hostname).gsub(/\.+$/, '') + self[:hostname_puny] = SimpleIDN.to_ascii(hostname).gsub(/\.+$/, '') end class << self diff --git a/app/views/mailers/csync_mailer/dnssec_deleted.html.erb b/app/views/mailers/csync_mailer/dnssec_deleted.html.erb new file mode 100644 index 000000000..5729c5e02 --- /dev/null +++ b/app/views/mailers/csync_mailer/dnssec_deleted.html.erb @@ -0,0 +1,14 @@ +Tere, +

+Oleme eemaldanud Teie domeeni <%= @domain.name %> DNSSEC kirjed registrist. + +Domeeni <%= @domain.name %> DNSSEC on nüüd välja lülitatud. +<%= render 'mailers/shared/signatures/signature.et.html' %> +
+

+Hi, +

+We have removed DNSSEC data in our registry for domain <%= @domain.name %>. +
+DNSSEC has been turned off for <%= @domain.name %> as of now. +<%= render 'mailers/shared/signatures/signature.en.html' %> diff --git a/app/views/mailers/csync_mailer/dnssec_updated.html.erb b/app/views/mailers/csync_mailer/dnssec_updated.html.erb new file mode 100644 index 000000000..916154f72 --- /dev/null +++ b/app/views/mailers/csync_mailer/dnssec_updated.html.erb @@ -0,0 +1,14 @@ +Tere, +

+Oleme uuendatud Teie domeeni <%= @domain.name %> DNSSEC andmeid registris. +
+Lisasime CDNSKEY väärtuses kajastatud võtme oma tsoonifaili ning on nüüd aktiveeritud. +<%= render 'mailers/shared/signatures/signature.et.html' %> +
+

+Hi, +

+We have updated DNSSEC data in our registry for domain <%= @domain.name %>. +
+DNS key specified in CDNSKEY has been added to our zone file and is now taking effect. +<%= render 'mailers/shared/signatures/signature.en.html' %> diff --git a/config/application.yml.sample b/config/application.yml.sample index 88fc28a0d..02396aad3 100644 --- a/config/application.yml.sample +++ b/config/application.yml.sample @@ -39,8 +39,9 @@ ca_key_path: '/home/registry/registry/shared/ca/private/ca.key.pem' ca_key_password: 'your-root-key-password' directo_invoice_url: 'https://domain/ddddd.asp' - - +cdns_scanner_input_file: '/opt/cdns/input.txt' +cdns_scanner_output_file: '/opt/cdns/output.txt' +dnssec_resolver_ips: 8.8.8.8, 8.8.4.4 # # EPP # @@ -192,6 +193,10 @@ test: action_mailer_force_delete_from: 'legal@registry.test' lhv_p12_keystore: 'test/fixtures/files/keystore.p12' lhv_keystore_password: 'testtest' + lhv_keystore_alias: 'testtest' + cdns_scanner_input_file: 'tmp/cdns_input.txt' + cdns_scanner_output_file: 'test/fixtures/files/cdns_output.txt' + dnssec_resolver_ips: 8.8.8.8, 8.8.4.4 legal_documents_dir: 'test/fixtures/files' # Airbrake // Errbit: diff --git a/config/locales/admin/menu.en.yml b/config/locales/admin/menu.en.yml index 52f0a210b..8fd8bdc07 100644 --- a/config/locales/admin/menu.en.yml +++ b/config/locales/admin/menu.en.yml @@ -16,6 +16,7 @@ en: disputed_domains: Disputed domains bulk_actions: Bulk actions bounced_email_addresses: Bounced emails + mass_actions: Mass actions epp_log: EPP log repp_log: REPP log que: Que diff --git a/config/locales/admin/repp_logs.en.yml b/config/locales/admin/repp_logs.en.yml index 2436f849a..0a58fe7ba 100644 --- a/config/locales/admin/repp_logs.en.yml +++ b/config/locales/admin/repp_logs.en.yml @@ -6,3 +6,4 @@ en: reset_btn: Reset show: title: REPP log + diff --git a/config/locales/mailers/csync.en.yml b/config/locales/mailers/csync.en.yml new file mode 100644 index 000000000..06f051689 --- /dev/null +++ b/config/locales/mailers/csync.en.yml @@ -0,0 +1,10 @@ +en: + csync_mailer: + dnssec_updated: + subject: >- + Teie domeeni %{domain_name} DNSSEC andmed on uuendatud + / DNSSEC data for %{domain_name} has been updated + dnssec_deleted: + subject: >- + Teie domeeni %{domain_name} DNSSEC andmed on eemaldatud + / DNSSEC data for %{domain_name} has been removed diff --git a/config/locales/notifications.en.yml b/config/locales/notifications.en.yml index d67fb5a5b..b5c1dfd47 100644 --- a/config/locales/notifications.en.yml +++ b/config/locales/notifications.en.yml @@ -6,5 +6,6 @@ en: It was associated with registrant %{old_registrant_code} and contacts %{old_contacts_codes}. contact_update: Contact %{contact} has been updated by registrant + csync: CSYNC DNSSEC %{action} for domain %{domain} registrar_locked: Domain %{domain_name} has been locked by registrant registrar_unlocked: Domain %{domain_name} has been unlocked by registrant diff --git a/db/migrate/20200605125332_create_csync_records.rb b/db/migrate/20200605125332_create_csync_records.rb new file mode 100644 index 000000000..69f6f09c8 --- /dev/null +++ b/db/migrate/20200605125332_create_csync_records.rb @@ -0,0 +1,12 @@ +class CreateCsyncRecords < ActiveRecord::Migration[6.0] + def change + create_table :csync_records do |t| + t.belongs_to :domain, foreign_key: true, null: false, index: { unique: true } + t.string :cdnskey, null: false + t.string :action, null: false + t.integer :times_scanned, null: false, default: 0 + t.datetime :last_scan, null: false + t.timestamps + end + end +end diff --git a/db/structure.sql b/db/structure.sql index de2243597..a28f7fabd 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1988,6 +1988,40 @@ CREATE TABLE public.log_users ( uuid character varying ); +-- +-- Name: csync_records; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.csync_records ( + id bigint NOT NULL, + domain_id bigint NOT NULL, + cdnskey character varying NOT NULL, + action character varying NOT NULL, + times_scanned integer DEFAULT 0 NOT NULL, + last_scan timestamp without time zone NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: csync_records_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.csync_records_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: csync_records_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.csync_records_id_seq OWNED BY public.csync_records.id; + -- -- Name: log_users_id_seq; Type: SEQUENCE; Schema: public; Owner: - @@ -2251,6 +2285,12 @@ CREATE TABLE public.registrant_verifications ( action character varying NOT NULL, domain_id integer NOT NULL, action_type character varying NOT NULL, + creator_id integer, + updater_id integer, + session character varying, + children json, + ident_updated_at timestamp without time zone, + uuid character varying, creator_str character varying, updator_str character varying ); @@ -3017,21 +3057,40 @@ ALTER TABLE ONLY public.que_jobs ALTER COLUMN job_id SET DEFAULT nextval('public ALTER TABLE ONLY public.registrant_verifications ALTER COLUMN id SET DEFAULT nextval('public.registrant_verifications_id_seq'::regclass); - -- -- Name: id; Type: DEFAULT; Schema: public; Owner: - -- +ALTER TABLE ONLY public.accounts ALTER COLUMN id SET DEFAULT nextval('public.accounts_id_seq'::regclass); + + +ALTER TABLE ONLY public.log_invoices + ADD CONSTRAINT log_invoices_pkey PRIMARY KEY (id); + + ALTER TABLE ONLY public.registrars ALTER COLUMN id SET DEFAULT nextval('public.registrars_id_seq'::regclass); -- -- Name: id; Type: DEFAULT; Schema: public; Owner: - -- +ALTER TABLE ONLY public.disputes ALTER COLUMN id SET DEFAULT nextval('public.disputes_id_seq'::regclass); + +ALTER TABLE ONLY public.log_nameservers + ADD CONSTRAINT log_nameservers_pkey PRIMARY KEY (id); + ALTER TABLE ONLY public.reserved_domains ALTER COLUMN id SET DEFAULT nextval('public.reserved_domains_id_seq'::regclass); + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.setting_entries ALTER COLUMN id SET DEFAULT nextval('public.setting_entries_id_seq'::regclass); + + -- -- Name: id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3050,6 +3109,285 @@ ALTER TABLE ONLY public.settings ALTER COLUMN id SET DEFAULT nextval('public.set -- Name: id; Type: DEFAULT; Schema: public; Owner: - -- +ALTER TABLE ONLY public.domains ALTER COLUMN id SET DEFAULT nextval('public.domains_id_seq'::regclass); + + +-- +-- Name: versions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_address_verifications ALTER COLUMN id SET DEFAULT nextval('public.email_address_verifications_id_seq'::regclass); + + +-- +-- Name: white_ips id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_addresses_validations ALTER COLUMN id SET DEFAULT nextval('public.email_addresses_validations_id_seq'::regclass); + + +-- +-- Name: whois_records id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_addresses_verifications ALTER COLUMN id SET DEFAULT nextval('public.email_addresses_verifications_id_seq'::regclass); + + +-- +-- Name: zones id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.epp_sessions ALTER COLUMN id SET DEFAULT nextval('public.epp_sessions_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_items ALTER COLUMN id SET DEFAULT nextval('public.invoice_items_id_seq'::regclass); + + +-- +-- Name: invoices id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices ALTER COLUMN id SET DEFAULT nextval('public.invoices_id_seq'::regclass); + + +-- +-- Name: legal_documents id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.legal_documents ALTER COLUMN id SET DEFAULT nextval('public.legal_documents_id_seq'::regclass); + + +-- +-- Name: log_account_activities id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_account_activities ALTER COLUMN id SET DEFAULT nextval('public.log_account_activities_id_seq'::regclass); + + +-- +-- Name: log_accounts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_accounts ALTER COLUMN id SET DEFAULT nextval('public.log_accounts_id_seq'::regclass); + + +-- +-- Name: log_actions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_actions ALTER COLUMN id SET DEFAULT nextval('public.log_actions_id_seq'::regclass); + + +-- +-- Name: log_bank_statements id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_bank_statements ALTER COLUMN id SET DEFAULT nextval('public.log_bank_statements_id_seq'::regclass); + + +-- +-- Name: log_bank_transactions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_bank_transactions ALTER COLUMN id SET DEFAULT nextval('public.log_bank_transactions_id_seq'::regclass); + + +-- +-- Name: log_blocked_domains id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_blocked_domains ALTER COLUMN id SET DEFAULT nextval('public.log_blocked_domains_id_seq'::regclass); + + +-- +-- Name: log_certificates id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_certificates ALTER COLUMN id SET DEFAULT nextval('public.log_certificates_id_seq'::regclass); + + +-- +-- Name: log_contacts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_contacts ALTER COLUMN id SET DEFAULT nextval('public.log_contacts_id_seq'::regclass); + + +-- +-- Name: log_dnskeys id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_dnskeys ALTER COLUMN id SET DEFAULT nextval('public.log_dnskeys_id_seq'::regclass); + + +-- +-- Name: log_domain_contacts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_domain_contacts ALTER COLUMN id SET DEFAULT nextval('public.log_domain_contacts_id_seq'::regclass); + + +-- +-- Name: log_domains id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_domains ALTER COLUMN id SET DEFAULT nextval('public.log_domains_id_seq'::regclass); + + +-- +-- Name: log_invoice_items id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_invoice_items ALTER COLUMN id SET DEFAULT nextval('public.log_invoice_items_id_seq'::regclass); + + +-- +-- Name: log_invoices id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_invoices ALTER COLUMN id SET DEFAULT nextval('public.log_invoices_id_seq'::regclass); + + +-- +-- Name: log_nameservers id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_nameservers ALTER COLUMN id SET DEFAULT nextval('public.log_nameservers_id_seq'::regclass); + + +-- +-- Name: log_notifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_notifications ALTER COLUMN id SET DEFAULT nextval('public.log_notifications_id_seq'::regclass); + + +-- +-- Name: log_payment_orders id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_payment_orders ALTER COLUMN id SET DEFAULT nextval('public.log_payment_orders_id_seq'::regclass); + + +-- +-- Name: log_registrant_verifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_registrant_verifications ALTER COLUMN id SET DEFAULT nextval('public.log_registrant_verifications_id_seq'::regclass); + + +-- +-- Name: log_registrars id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_registrars ALTER COLUMN id SET DEFAULT nextval('public.log_registrars_id_seq'::regclass); + + +-- +-- Name: log_reserved_domains id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_reserved_domains ALTER COLUMN id SET DEFAULT nextval('public.log_reserved_domains_id_seq'::regclass); + + +-- +-- Name: log_settings id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_settings ALTER COLUMN id SET DEFAULT nextval('public.log_settings_id_seq'::regclass); + + +-- +-- Name: log_users id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_users ALTER COLUMN id SET DEFAULT nextval('public.log_users_id_seq'::regclass); + + +-- +-- Name: log_white_ips id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.log_white_ips ALTER COLUMN id SET DEFAULT nextval('public.log_white_ips_id_seq'::regclass); + + +-- +-- Name: nameservers id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.nameservers ALTER COLUMN id SET DEFAULT nextval('public.nameservers_id_seq'::regclass); + + +-- +-- Name: notifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('public.notifications_id_seq'::regclass); + + +-- +-- Name: payment_orders id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_orders ALTER COLUMN id SET DEFAULT nextval('public.payment_orders_id_seq'::regclass); + + +-- +-- Name: prices id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.prices ALTER COLUMN id SET DEFAULT nextval('public.prices_id_seq'::regclass); + + +-- +-- Name: que_jobs job_id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.que_jobs ALTER COLUMN job_id SET DEFAULT nextval('public.que_jobs_job_id_seq'::regclass); + + +-- +-- Name: registrant_verifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.registrant_verifications ALTER COLUMN id SET DEFAULT nextval('public.registrant_verifications_id_seq'::regclass); + + +-- +-- Name: registrars id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.registrars ALTER COLUMN id SET DEFAULT nextval('public.registrars_id_seq'::regclass); + + +-- +-- Name: reserved_domains id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reserved_domains ALTER COLUMN id SET DEFAULT nextval('public.reserved_domains_id_seq'::regclass); + + +-- +-- Name: settings id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.settings ALTER COLUMN id SET DEFAULT nextval('public.settings_id_seq'::regclass); + + +-- +-- Name: users id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + +ALTER TABLE ONLY public.log_prices + ADD CONSTRAINT log_prices_pkey PRIMARY KEY (id); + + ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); @@ -3369,22 +3707,6 @@ ALTER TABLE ONLY public.log_invoice_items ADD CONSTRAINT log_invoice_items_pkey PRIMARY KEY (id); --- --- Name: log_invoices_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: --- - -ALTER TABLE ONLY public.log_invoices - ADD CONSTRAINT log_invoices_pkey PRIMARY KEY (id); - - --- --- Name: log_nameservers_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: --- - -ALTER TABLE ONLY public.log_nameservers - ADD CONSTRAINT log_nameservers_pkey PRIMARY KEY (id); - - -- -- Name: log_notifications_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: -- @@ -3401,14 +3723,6 @@ ALTER TABLE ONLY public.log_payment_orders ADD CONSTRAINT log_payment_orders_pkey PRIMARY KEY (id); --- --- Name: log_prices_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.log_prices - ADD CONSTRAINT log_prices_pkey PRIMARY KEY (id); - - -- -- Name: log_registrant_verifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4006,6 +4320,17 @@ CREATE INDEX index_log_contacts_on_whodunnit ON public.log_contacts USING btree CREATE INDEX index_log_dnskeys_on_item_type_and_item_id ON public.log_dnskeys USING btree (item_type, item_id); +-- +-- Name: csync_records id; Type: DEFAULT; Schema: public; Owner: - +-- +ALTER TABLE ONLY public.csync_records ALTER COLUMN id SET DEFAULT nextval('public.csync_records_id_seq'::regclass); + +-- +-- Name: index_csync_records_on_domain_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_csync_records_on_domain_id ON public.csync_records USING btree (domain_id); + -- -- Name: index_log_dnskeys_on_whodunnit; Type: INDEX; Schema: public; Owner: -; Tablespace: -- @@ -4313,9 +4638,8 @@ CREATE UNIQUE INDEX unique_data_migrations ON public.data_migrations USING btree CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING btree (version); - -- --- Name: contacts_registrar_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: contacts contacts_registrar_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.contacts @@ -4521,6 +4845,19 @@ ALTER TABLE ONLY public.nameservers ALTER TABLE ONLY public.users ADD CONSTRAINT user_registrar_id_fk FOREIGN KEY (registrar_id) REFERENCES public.registrars(id); +-- +-- Name: csync_records csync_records_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.csync_records + ADD CONSTRAINT csync_records_pkey PRIMARY KEY (id); + +-- +-- Name: csync_records fk_rails_5df85aeb13; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.csync_records + ADD CONSTRAINT fk_rails_5df85aeb13 FOREIGN KEY (domain_id) REFERENCES public.domains(id); -- -- PostgreSQL database dump complete @@ -4951,6 +5288,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20200605100827'), ('20200610090110'), ('20200630081231'), +('20200605125332'), ('20200714115338'), ('20200807110611'), ('20200811074839'), @@ -4961,6 +5299,6 @@ INSERT INTO "schema_migrations" (version) VALUES ('20200910085157'), ('20200910102028'), ('20200916125326'), -('20210215101019'); - - +('20200917104213'), +('20210215101019'), +('20200921084356'); diff --git a/test/fixtures/dnskeys.yml b/test/fixtures/dnskeys.yml new file mode 100644 index 000000000..218c14ba6 --- /dev/null +++ b/test/fixtures/dnskeys.yml @@ -0,0 +1,10 @@ +one: + domain: + flags: 257 + protocol: 3 + alg: 13 + public_key: mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ== + ds_key_tag: 2371 + ds_alg: 13 + ds_digest_type: 2 + ds_digest: 39456058862EA09DD96992ED2BDAFAEDE8C7E949589E3DA903A46F4F9CD373EA diff --git a/test/fixtures/files/cdns_output.txt b/test/fixtures/files/cdns_output.txt new file mode 100644 index 000000000..9299ec195 --- /dev/null +++ b/test/fixtures/files/cdns_output.txt @@ -0,0 +1,4 @@ +insecure-empty ns1.bestnames.test 127.0.0.1 airport.test +insecure-empty ns2.bestnames.test 127.0.0.1 airport.test +insecure ns1.bestnames.test 127.0.0.1 shop.test 257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ== +insecure ns2.bestnames.test 127.0.0.1 shop.test 257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ== diff --git a/test/jobs/csync_job_test.rb b/test/jobs/csync_job_test.rb new file mode 100644 index 000000000..8819fd5c7 --- /dev/null +++ b/test/jobs/csync_job_test.rb @@ -0,0 +1,73 @@ +require 'test_helper' + +class CsyncJobTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + setup do + @dnskey = dnskeys(:one) + @domain = domains(:shop) + end + + def test_generates_input_file_for_cdnskey_scanner + @dnskey.update(domain: domains(:shop)) + + expected_contents = "[secure]\nns1.bestnames.test shop.test\nns2.bestnames.test shop.test\n" \ + "[insecure]\nns1.bestnames.test airport.test metro.test\nns2.bestnames.test airport.test\n" + + CsyncJob.run(generate: true) + + assert_equal expected_contents, IO.read(ENV['cdns_scanner_input_file']) + end + + def test_creates_csync_record_when_new_cdnskey_discovered + assert_nil @domain.csync_record + CsyncJob.run + + @domain.reload + assert @domain.csync_record + csync_record = @domain.csync_record + assert_equal 1, csync_record.times_scanned + assert_equal '257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==', csync_record.cdnskey + + assert_not @domain.dnskeys.any? + end + + def test_creates_dnskey_after_required_cycles + assert_equal 0, @domain.dnskeys.count + assert_nil @domain.csync_record + CsyncJob.run # Creates initial CsyncRecord for domain + + @domain.reload + assert @domain.csync_record.present? + + @domain.csync_record.update(times_scanned: 2) # 3rd time trigger DNSKEY push + assert_equal 0, @domain.dnskeys.count + assert_equal 2, @domain.csync_record.times_scanned + + CsyncRecord.stub :by_domain_name, @domain.csync_record do + @domain.csync_record.stub :dnssec_validates?, true do + CsyncJob.run + end + end + + @domain.reload + assert_equal 1, @domain.dnskeys.count + assert_equal 'mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==', @domain.dnskeys.last.public_key + assert_nil @domain.csync_record + end + + def test_sends_mail_to_contacts_if_dnskey_updated + assert_emails 1 do + CsyncJob.run + @domain.reload + + CsyncRecord.stub :by_domain_name, @domain.csync_record do + @domain.csync_record.stub :dnssec_validates?, true do + 2.times do + CsyncJob.run + end + end + end + end + end +end diff --git a/test/models/contact_test.rb b/test/models/contact_test.rb index 942b71b06..e6dc2422f 100644 --- a/test/models/contact_test.rb +++ b/test/models/contact_test.rb @@ -291,37 +291,45 @@ class ContactTest < ActiveSupport::TestCase assert_equal 'US', contact.country_code end - def test_normalizes_ident_country_code - contact = Contact.new(ident_country_code: 'us') - contact.validate - assert_equal 'US', contact.ident_country_code + def test_linked_scope_returns_contact_that_acts_as_admin_contact + domains(:shop).admin_contacts = [@contact] + assert Contact.linked.include?(@contact), 'Contact should be included' end - def test_generates_code - contact = Contact.new(registrar: registrars(:bestnames)) - assert_nil contact.code - - contact.generate_code - - assert_not_empty contact.code + def test_linked_scope_returns_contact_that_acts_as_tech_contact + domains(:shop).tech_contacts = [@contact] + assert Contact.linked.include?(@contact), 'Contact should be included' end - def test_prohibits_code_change - assert_no_changes -> { @contact.code } do - @contact.code = 'new' - @contact.save! - @contact.reload - end + def test_linked_scope_skips_unlinked_contact + contact = unlinked_contact + assert_not Contact.linked.include?(contact), 'Contact should be excluded' end - def test_removes_duplicate_statuses - contact = Contact.new(statuses: %w[ok ok]) - assert_equal %w[ok], contact.statuses + def test_unlinked_scope_returns_unlinked_contact + contact = unlinked_contact + assert Contact.unlinked.include?(contact), 'Contact should be included' end - def test_default_status - contact = Contact.new - assert_equal %w[ok], contact.statuses + def test_unlinked_scope_skips_contact_that_is_linked_as_registrant + contact = unlinked_contact + domains(:shop).update_columns(registrant_id: contact.becomes(Registrant).id) + + assert Contact.unlinked.exclude?(contact), 'Contact should be excluded' + end + + def test_unlinked_scope_skips_contact_that_is_linked_as_admin_contact + contact = unlinked_contact + domains(:shop).admin_contacts = [contact] + + assert Contact.unlinked.exclude?(contact), 'Contact should be excluded' + end + + def test_unlinked_scope_skips_contact_that_is_linked_as_tech_contact + contact = unlinked_contact + domains(:shop).tech_contacts = [contact] + + assert Contact.unlinked.exclude?(contact), 'Contact should be excluded' end def test_whois_gets_updated_after_contact_save diff --git a/test/models/csync_record_test.rb b/test/models/csync_record_test.rb new file mode 100644 index 000000000..9c044e63d --- /dev/null +++ b/test/models/csync_record_test.rb @@ -0,0 +1,289 @@ +require 'test_helper' + +class CsyncRecordTest < ActiveSupport::TestCase + setup do + @domain = domains(:shop) + + @csync_record = CsyncRecord.new + @csync_record.cdnskey = '257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==' + @csync_record.action = 'initialized' + @csync_record.domain = domains(:shop) + @csync_record.last_scan = Time.now + end + + def test_domain_must_be_present + assert @csync_record.valid? + @csync_record.domain = nil + assert_not @csync_record.valid? + end + + def test_action_must_be_present_and_valid + @csync_record.action = nil + assert_not @csync_record.valid? + + @csync_record.action = 'definitely invalid action' + assert_not @csync_record.valid? + + @csync_record.action = 'initialized' + assert @csync_record.valid? + + @csync_record.action = 'rollover' + assert @csync_record.valid? + + @csync_record.action = 'deactivate' + assert_not @csync_record.valid? + + @csync_record.action = 'deactivate' + @csync_record.cdnskey = '0 3 0 AA==' + assert @csync_record.valid? + end + + def test_cdnskey_must_be_unique_for_domain + dnskey = @csync_record.dnskey + dnskey.save! + + assert_not @csync_record.valid? + assert_includes @csync_record.errors.full_messages, 'Public key already tied to this domain' + + @csync_record.cdnskey = nil + assert_not @csync_record.valid? + assert_includes @csync_record.errors.full_messages, 'Cdnskey is missing' + end + + def test_cdnskey_must_be_parsable + @csync_record.cdnskey = 'gibberish' + assert_not @csync_record.valid? + + @csync_record.cdnskey = nil + assert_not @csync_record.valid? + @csync_record.cdnskey = '' + assert_not @csync_record.valid? + + @csync_record.cdnskey = '257 3 13 KlHFYV42UtxC7LpsolDpoUZ9DNPDRYQypalBRIqlubBg/zg78aqciLk+NaWUbrkN7AUaM7h7tx91sLN+ORVPxA==' + assert @csync_record.valid? + end + + def test_initializes_valid_record_with_scanner_input + scanner_result = { + type: 'insecure', ns: 'ns1.bestnames.test', ns_ip: '127.0.0.1', flags: '257', proto: '3', alg: '13', + pub: 'mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==', + cdnskey: '257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==' + } + + csync_record = CsyncRecord.by_domain_name(@domain.name) + csync_record.assign_scanner_data!(scanner_result) + + assert csync_record.valid? + assert_equal scanner_result[:cdnskey], csync_record.cdnskey + assert_equal 'initialized', csync_record.action + assert_equal 1, csync_record.times_scanned + end + + def test_creates_rollover_record_with_scanner_input + # Rollover always requires valid dnssec conf beforehand + dnskey = @csync_record.dnskey + dnskey.save! + + # Type 'secure' reflects from cdnskey-scanner that custom domain security level is SECURE (dnssec valid) + scanner_result = { + type: 'secure', ns: 'ns1.bestnames.test', ns_ip: '127.0.0.1', flags: '256', proto: '3', alg: '13', + pub: 'PiVTNvqOTrCSoXf5obNEPrDe0yhrKPmjyv+MWfoscBHF49rRIH1/yDdAXY3SyUD86qq/AiXDzsTQIqOjvak7gw==', + cdnskey: '256 3 13 PiVTNvqOTrCSoXf5obNEPrDe0yhrKPmjyv+MWfoscBHF49rRIH1/yDdAXY3SyUD86qq/AiXDzsTQIqOjvak7gw==' + } + + csync_record = CsyncRecord.by_domain_name(@domain.name) + csync_record.assign_scanner_data!(scanner_result) + assert_equal 'rollover', csync_record.action + assert csync_record.valid? + + scanner_result[:type] = 'insecure' + csync_record.assign_scanner_data!(scanner_result) + assert_not csync_record.valid? + assert_includes csync_record.errors.full_messages, 'Action is invalid' + end + + def test_creates_deactivate_record_with_scanner_input + # Deactivate always requires valid dnssec conf beforehand + dnskey = @csync_record.dnskey + dnskey.save! + + # Type 'secure' reflects from cdnskey-scanner that custom domain security level is SECURE (dnssec valid) + scanner_result = { + type: 'secure', ns: 'ns1.bestnames.test', ns_ip: '127.0.0.1', flags: '0', proto: '3', alg: '0', + pub: 'AA==', + cdnskey: '0 3 0 AA==' + } + + csync_record = CsyncRecord.by_domain_name(@domain.name) + csync_record.assign_scanner_data!(scanner_result) + assert_equal 'deactivate', csync_record.action + assert csync_record.valid? + + scanner_result[:type] = 'insecure' + csync_record.assign_scanner_data!(scanner_result) + assert_not csync_record.valid? + assert_includes csync_record.errors.full_messages, 'Action is invalid' + end + + def test_initializes_dnssec_for_domain + scanner_result = { + type: 'insecure', ns: 'ns1.bestnames.test', ns_ip: '127.0.0.1', flags: '257', proto: '3', alg: '13', + pub: 'mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==', + cdnskey: '257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==' + } + + 2.times { CsyncRecord.by_domain_name(@domain.name).record_new_scan(scanner_result) } + @domain.reload + + CsyncRecord.stub :by_domain_name, @domain.csync_record do + @domain.csync_record.stub :dnssec_validates?, true do + CsyncRecord.by_domain_name(@domain.name).record_new_scan(scanner_result) + end + end + + assert_equal 1, @domain.dnskeys.count + assert_equal scanner_result[:pub], @domain.dnskeys.last.public_key + + mail = ActionMailer::Base.deliveries.last + assert_equal (@domain.contacts.map(&:email) << @domain.registrant.email).uniq, mail.to + assert_equal mail.subject, "Teie domeeni #{@domain.name} DNSSEC andmed on uuendatud / DNSSEC data for #{@domain.name} has been updated" + end + + def test_rollovers_dnssec_for_domain + dnskey = @csync_record.dnskey + dnskey.save! + + scanner_result = { + type: 'secure', ns: 'ns1.bestnames.test', ns_ip: '127.0.0.1', flags: '256', proto: '3', alg: '13', + pub: 'PiVTNvqOTrCSoXf5obNEPrDe0yhrKPmjyv+MWfoscBHF49rRIH1/yDdAXY3SyUD86qq/AiXDzsTQIqOjvak7gw==', + cdnskey: '256 3 13 PiVTNvqOTrCSoXf5obNEPrDe0yhrKPmjyv+MWfoscBHF49rRIH1/yDdAXY3SyUD86qq/AiXDzsTQIqOjvak7gw==' + } + + stub_any_instance(CsyncRecord, :dnssec_validates?, true) do + CsyncRecord.by_domain_name(@domain.name).record_new_scan(scanner_result) + end + + assert_equal 1, @domain.dnskeys.count + assert_equal scanner_result[:pub], @domain.dnskeys.last.public_key + + mail = ActionMailer::Base.deliveries.last + assert_equal (@domain.contacts.map(&:email) << @domain.registrant.email).uniq, mail.to + assert_equal mail.subject, "Teie domeeni #{@domain.name} DNSSEC andmed on uuendatud / DNSSEC data for #{@domain.name} has been updated" + end + + def test_deactivates_dnssec_for_domain + dnskey = @csync_record.dnskey + dnskey.save! + + scanner_result = { + type: 'secure', ns: 'ns1.bestnames.test', ns_ip: '127.0.0.1', flags: '0', proto: '3', alg: '0', + pub: 'AA==', + cdnskey: '0 3 0 AA==' + } + + stub_any_instance(CsyncRecord, :dnssec_validates?, true) do + CsyncRecord.by_domain_name(@domain.name).record_new_scan(scanner_result) + end + + assert @domain.dnskeys.empty? + + mail = ActionMailer::Base.deliveries.last + assert_equal (@domain.contacts.map(&:email) << @domain.registrant.email).uniq, mail.to + assert_equal mail.subject, "Teie domeeni #{@domain.name} DNSSEC andmed on eemaldatud / DNSSEC data for #{@domain.name} has been removed" + end + + def test_validates_security_level_for_initialize_action + @csync_record.action = 'initialized' + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.INSECURE do + assert @csync_record.valid_pre_action? + assert_not @csync_record.valid_post_action? + end + + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.BOGUS do + assert @csync_record.valid_pre_action? + assert_not @csync_record.valid_post_action? + end + + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.SECURE do + assert @csync_record.valid_post_action? + assert_not @csync_record.valid_pre_action? + end + end + + def test_validates_security_level_for_rollover_action + @csync_record.action = 'rollover' + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.INSECURE do + assert_not @csync_record.valid_pre_action? + assert_not @csync_record.valid_post_action? + end + + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.BOGUS do + assert_not @csync_record.valid_pre_action? + assert_not @csync_record.valid_post_action? + end + + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.SECURE do + assert @csync_record.valid_pre_action? + assert @csync_record.valid_post_action? + end + end + + def test_validates_security_level_for_deactivate_action + @csync_record.action = 'deactivate' + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.INSECURE do + assert @csync_record.valid_post_action? + assert_not @csync_record.valid_pre_action? + end + + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.BOGUS do + assert @csync_record.valid_post_action? + assert_not @csync_record.valid_pre_action? + end + + @domain.stub :dnssec_security_level, Dnsruby::Message::SecurityLevel.SECURE do + assert @csync_record.valid_pre_action? + assert_not @csync_record.valid_post_action? + end + end + + def test_returns_correct_result_if_pre_and_post_actions_succeed + stub_any_instance(CsyncRecord, :valid_post_action?, true) do + stub_any_instance(CsyncRecord, :valid_pre_action?, true) do + assert @csync_record.dnssec_validates? + end + end + + stub_any_instance(CsyncRecord, :valid_post_action?, false) do + stub_any_instance(CsyncRecord, :valid_pre_action?, false) do + assert_not @csync_record.dnssec_validates? + end + end + end + + def test_dnssec_validation_fails_if_domain_unreachable + @domain.name = 'definitely.not.valid.domain.123' + assert_not @csync_record.dnssec_validates? + end + + def stub_any_instance(klass, method, value) + klass.class_eval do + alias_method :"new_#{method}", method + + define_method(method) do + if value.respond_to?(:call) + value.call + else + value + end + end + end + + yield + ensure + klass.class_eval do + undef_method method + alias_method method, :"new_#{method}" + undef_method :"new_#{method}" + end + end +end