diff --git a/app/models/record.rb b/app/models/record.rb index d73f2c5..a3186fa 100644 --- a/app/models/record.rb +++ b/app/models/record.rb @@ -1,333 +1,341 @@ require 'ipaddr' require_dependency 'drop_privileges_validator' class Record < ActiveRecord::Base belongs_to :domain # Powerdns inserts empty records on slave zones, # we want to hide them # # http://mailman.powerdns.com/pipermail/pdns-users/2013-December/010389.html default_scope { where.not(type: nil) } # List all supported DNS RR types. def self.record_types [ 'A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SPF', 'SRV', 'SSHFP', 'SOA', 'NS', 'PTR', 'NAPTR', 'DS' ] end # List types usually used in forward zones. def self.forward_records record_types - ['SOA', 'PTR'] end # List types usually used in reverse zones. def self.reverse_records ['PTR', 'CNAME', 'TXT', 'NS', 'NAPTR', 'DS'] end # List types usually used in enum zones. def self.enum_records ['NAPTR', 'CNAME', 'TXT', 'NS', 'DS'] end # List types that can be touched by a simple user. def self.allowed_record_types record_types - WebDNS.settings[:prohibit_records_types] end validates :name, presence: true validates :type, inclusion: { in: record_types } # http://mark.lindsey.name/2009/03/never-use-dns-ttl-of-zero-0.html validates_numericality_of :ttl, allow_nil: true, # Default pdns TTL only_integer: true, greater_than: 0, less_than_or_equal_to: 2_147_483_647 # Don't allow the following actions on drop privileges mode validate :no_touching_for_slave_zones, if: -> { domain.slave? } validates_drop_privileges :type, message: 'You cannot touch that record!', unless: -> { Record.allowed_record_types.include?(type) } validates_drop_privileges :name, message: 'You cannot touch top level NS records!', if: -> { type == 'NS' && domain_record? } validates_uniqueness_of :name, :scope => [:domain, :type, :content], message: "There already exists a record with the same name, type and content." before_validation :guess_reverse_name before_validation :set_name after_save :update_zone_serial after_destroy :update_zone_serial + before_create :validate_unique_cname before_create :generate_classless_delegations, unless: -> { domain.slave? } before_destroy :delete_classless_delegations, unless: -> { domain.slave? } # Smart sort a list of records. # # Order by: # * Top level records # * Record name # * SOA # * NS # * Friendly type # * Priority # * Content # # records - The list of records to order. # # Returns the list sorted. def self.smart_order(records) records.sort_by { |r| [ r.domain_record? ? 0 : 1, # Zone records r.classless_delegated? ? 1 : 0, r.name, r.type == 'SOA' ? 0 : 1, r.type == 'NS' ? 0 : 1, record_types.index(r.type), # Friendly type r.prio || 0, r.content ] } end def self.search(query) wild_search = "%#{query}%" # !index_friendly where('name like :q or content like :q', q: wild_search) end # Get the a short name for the record (without the zone suffix). # # Returns a string. def short return '' if name == domain.name return '' if name.blank? File.basename(name, ".#{domain.name}") end # Returns true if this is a zone record. def domain_record? name.blank? || name == domain.name end # Find out if the record is edittable. # # by - Editable by :user or :admin. # # Returns true if the record is editable. def editable?(by = :user) return false if domain.slave? return false if classless_delegated? case by when :user return false unless Record.allowed_record_types.include?(type) return false if type == 'NS' && domain_record? end true end # Find out this record type supports priorities. # # We set this to false by default, record types that support priorities. # shoule override this. # # Returns true this record type support priorities. def supports_prio? false end # Make sure rails generates record specific urls for all record types. # # Overrides default rails STI behavior. def self.model_name return super if self == Record Record.model_name end # Generate the usual admin friendly DNS record line. # # Returns a string. def to_dns [name, ttl, 'IN', type, supports_prio? ? prio : nil, content].compact.join(' ') end # Generate a shorter version of the DNS record line. # # Returns a string. def to_short_dns [name, 'IN', type].join(' ') end def to_api Hash[ :name, name, :content, content, :type, type, :ttl, ttl, :prio, prio, :disabled, disabled ].with_indifferent_access end def classless_delegated? return false if not type == 'CNAME' return false if not domain.name.end_with?('.in-addr.arpa') network, mask = parse_delegation(content) return false if network.nil? octet = name.split('.').first.to_i return true if octet >= network return true if octet <= network + 2 ^ (32 - mask) - 1 # max false end def classless_delegation? return true if classless_delegation false end def as_bulky_json Hash[ id: id, name: name, type: type, ttl: ttl, prio: prio, content: content, disabled: disabled ] end private # Validations def no_touching_for_slave_zones # Allow automatic SOA creation for slave zones # powerdns needs a valid serial to compare it with master return if type == 'SOA' && validation_context == :create errors.add(:type, 'This is a slave zone!') end # Hooks def guess_reverse_name return if not type == 'PTR' return if not domain.reverse? return if name.blank? reverse = IPAddr.new(name).reverse self.name = reverse if reverse.end_with?(domain.name) rescue IPAddr::InvalidAddressError # rubycop:disable HandleExceptions end # Powerdns expects full domain names def set_name self.name = domain.name if name.blank? self.name = "#{name}.#{domain.name}" if not name.end_with?(domain.name) end def remove_terminating_dot self.content = content.gsub(/\.+\Z/, '') end def update_zone_serial # SOA records handle serial themselves return true if type == 'SOA' return true if !domain domain.soa.bump_serial! end def classless_delegation return if not type == 'NS' return if not domain.name.end_with?('.in-addr.arpa') network, mask = parse_delegation(name) return if network.nil? range = IPAddr.new("0.0.0.#{network}/#{mask}").to_range return if !range.first.to_s.end_with?(".#{network}") range.map { |ip| octet = ip.to_s.split('.').last "#{octet}.#{domain.name}" } end def parse_delegation(value) first, _rest = value.split('.', 2) first.gsub!('-', '/') return if !first['/'] network, mask = first.split('/', 2).map { |i| Integer(i).abs } return if [network, mask].join('/') != first return if mask <= 24 return if mask > 31 return if network > 255 [network, mask] rescue ArgumentError # Not an integer end def delete_classless_delegations rnames = classless_delegation return unless rnames # Check if we have another NS for the same delegation return if domain.records.where(type: 'NS', name: name) .where.not(id: id).exists? # Delete all CNAMEs domain.records.where(name: rnames, type: 'CNAME', content: name).delete_all end def generate_classless_delegations rnames = classless_delegation return unless rnames # Make sure no record exists for a delegated domain if domain.records.where(name: rnames) .where.not(content: name).exists? errors.add(:name, 'Records already exist for the delegated octets!') return false end rnames.each { |rname| CNAME.find_or_create_by!( domain: domain, name: rname, content: name ) } end def self.to_fqdn(name, domain) return name if name.end_with?(domain) "#{name}.#{domain}" end + + def validate_unique_cname + return if !domain.records.where(type: 'CNAME', name: name).exists? + + errors.add(:name, 'There exists a CNAME record with the same name') + return false + end end diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index ef781a0..23dc784 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -1,437 +1,450 @@ require 'test_helper' class DomainTest < ActiveSupport::TestCase def setup @domain = build(:domain) end test 'automatic SOA creation' do @domain.save! @domain.reload assert_not_nil @domain.soa end test 'increment serial on new record' do @domain.save! soa = @domain.soa assert_serial_update soa do www = A.new(name: 'www', domain: @domain, content: '1.2.3.4') www.save! end end test 'increment serial on record update' do @domain.save! www = A.new(name: 'www', domain: @domain, content: '1.2.3.4') www.save! soa = @domain.soa.reload assert_serial_update soa do www.content = '1.2.3.5' www.save! end end test 'uniqueness of records' do @domain.save! rec = Record.new(type: 'A', name: 'www', domain: @domain, content: '1.2.3.4') rec.save! assert_empty rec.errors rec2 = Record.new(type: 'A', name: 'www', domain: @domain, content: '1.2.3.4') assert_raises(ActiveRecord::RecordInvalid) { rec2.save! } assert_not_empty rec2.errors[:name] rec3 = Record.new(type: 'A', name: 'www2', domain: @domain, content: '1.2.3.4') rec3.save! assert_empty rec3.errors rec4 = Record.new(type: 'A', name: 'www', domain: @domain, content: '1.2.3.5') rec4.save! assert_empty rec4.errors rec5 = Record.new(type: 'AAAA', name: 'www', domain: @domain, content: '2001:0db8:85a3:0000:0000:8a2e:0370:7334') rec5.save! assert_empty rec5.errors end test 'automatic NS creation' do @domain.save! @domain.reload assert_equal WebDNS.settings[:default_ns].sort, @domain.records.where(type: 'NS').pluck(:content).sort end test 'increment serial on record destroy' do @domain.save! www = A.new(name: 'www', domain: @domain, content: '1.2.3.4') www.save! soa = @domain.soa.reload assert_serial_update soa do www.destroy! end end + test 'do not allow other record types when there is a cname' do + @domain.save! + name = 'mysupername' + + cname = CNAME.new(name: name, domain: @domain, content: 'whocares') + cname.save! + assert_empty cname.errors + + www = A.new(name: name, domain: @domain, content: '1.2.3.4') + assert_raises(ActiveRecord::RecordNotSaved) { www.save! } + assert_not_empty www.errors[:name] + end + class SlaveDomainTest < ActiveSupport::TestCase def setup @domain = build(:slave) end test 'saves' do @domain.save assert_empty @domain.errors end test 'automatic SOA creation' do @domain.save! @domain.reload assert_not_nil @domain.soa assert_equal 1, @domain.soa.serial end test 'validates master' do @domain.master = 'not-an-ip' @domain.save assert_not_empty @domain.errors['master'] end test 'no records are allowed for users' do @domain.save! rec = build(:a, domain_id: @domain.id) assert_not rec.valid? assert_not_empty rec.errors[:type] end end class StatesDomainTest < ActiveSupport::TestCase def setup @domain = build(:domain) @policy = DnssecPolicy.all[0] end test 'domain lifetime' do assert_equal 'initial', @domain.state # Create assert_jobs do @domain.save! # user triggered assert_equal 'pending_install', @domain.state end @domain.installed # job triggered assert_equal 'operational', @domain.state # Convert to dnssec (sign) assert_jobs do @domain.dnssec = true @domain.dnssec_policy = @policy @domain.dnssec_parent = @domain.name.split('.', 2).last @domain.dnssec_parent_authority = 'test_authority' @domain.save! # After commit is not triggered in tests, # so we have to trigger it manually @domain.send(:after_commit_event) assert_equal 'pending_signing', @domain.state end assert_jobs do assert @domain.signed # job triggered assert_equal 'wait_for_ready', @domain.state end # Convert to dnssec (publish ds) assert_jobs do assert @domain.push_ds(['dss1', 'dss2']) # triggered by ds-schedule script assert_equal 'pending_ds', @domain.state end assert @domain.converted # job triggered assert_equal 'operational', @domain.state # KSK rollover assert_jobs do assert @domain.push_ds(['dss3', 'dss4']) # triggered by ds-schedule script assert_equal 'pending_ds_rollover', @domain.state end assert @domain.complete_rollover # job triggered assert_equal 'operational', @domain.state # Convert to plain assert_jobs do assert @domain.plain_convert # user triggered assert_equal 'pending_plain', @domain.state end assert @domain.converted # job triggered assert_equal 'operational', @domain.state # Remove assert_jobs do assert @domain.remove # user triggered assert_equal 'pending_remove', @domain.state end assert @domain.cleaned_up # job triggered assert_equal 'destroy', @domain.state end test 'domain lifetime #full-destroy' do assert_equal 'initial', @domain.state # Create assert_jobs do @domain.save! # user triggered assert_equal 'pending_install', @domain.state end @domain.installed # job triggered assert_equal 'operational', @domain.state # Convert to dnssec (sign) assert_jobs do @domain.dnssec = true @domain.dnssec_policy = @policy @domain.dnssec_parent = @domain.name.split('.', 2).last @domain.dnssec_parent_authority = 'test_authority' @domain.save! # After commit is not triggered in tests, # so we have to trigger it manually @domain.send(:after_commit_event) assert_equal 'pending_signing', @domain.state end assert_jobs do assert @domain.signed # job triggered assert_equal 'wait_for_ready', @domain.state end # Convert to dnssec (publish ds) assert_jobs do assert @domain.push_ds(['dss1', 'dss2']) # triggered by ds-schedule script assert_equal 'pending_ds', @domain.state end assert @domain.converted # job triggered assert_equal 'operational', @domain.state # KSK rollover assert_jobs do assert @domain.push_ds(['dss3', 'dss4']) # triggered by ds-schedule script assert_equal 'pending_ds_rollover', @domain.state end assert @domain.complete_rollover # job triggered assert_equal 'operational', @domain.state # Full Remove (Drops DS records) assert_jobs do assert @domain.full_remove # user triggered assert_equal 'pending_ds_removal', @domain.state end assert_jobs do assert @domain.remove # job triggered assert_equal 'pending_remove', @domain.state end assert @domain.cleaned_up # job triggered assert_equal 'destroy', @domain.state end end class DsDomainTest < ActiveSupport::TestCase def setup @domain = create(:domain) @ds = [ '31406 8 1 189968811e6eba862dd6c209f75623d8d9ed9142', '31406 8 2 f78cf3344f72137235098ecbbd08947c2c9001c7f6a085a17f518b5d8f6b916d', ] @child = "dnssec.#{@domain.name}" @extra = DS.create(domain: @domain, name: @child, content: 'other') end test 'add ds records' do Domain.replace_ds(@domain.name, @child, @ds) @extra.save! # Should be deleted assert_equal @ds.size, DS.where(name: "dnssec.#{@domain.name}").count @ds.each { |ds| assert_equal 1, DS.where(name: "dnssec.#{@domain.name}", content: ds).count } end test 'remove ds records' do Domain.replace_ds(@domain.name, @child, []) assert_equal 0, DS.where(name: "dnssec.#{@domain.name}").count end test 'check if child is a valid subdomain' do assert_raise Domain::NotAChild do Domain.replace_ds(@domain.name, 'dnssec.example.net', @ds) end end end class BulkTest < ActiveSupport::TestCase def setup @domain = create(:domain) @a = create(:a, domain: @domain) @aaaa = create(:aaaa, domain: @domain) @new = build(:mx, domain: @domain) end def valid_changes @valid_changes ||= begin {}.tap { |c| c[:deletes] = [@a.id] c[:changes] = { @aaaa.id => { content: '::42' }} c[:additions] = { 1 => @new.as_bulky_json } } end end def invalid_changes @invalid_changes ||= begin {}.tap { |c| c[:deletes] = [Record.maximum(:id) + 1] c[:changes] = { @aaaa.id => { content: '1.2.3.4' }} c[:additions] = { 1 => @new.as_bulky_json.update(prio: -1) } } end end test 'apply changes not' do ops, err = @domain.bulk invalid_changes assert_not_empty err assert_includes err[:deletes][Record.maximum(:id) + 1], 'record not found' assert_includes err[:changes][@aaaa.id], 'not a valid IPv6' assert_includes err[:additions][1], 'not a valid DNS priority' end test 'apply changes' do ops, err = @domain.bulk valid_changes @domain.reload @aaaa.reload assert_empty err assert_empty @domain.records.where(id: @a.id) assert_equal '::42', @aaaa.content assert_equal 1, @domain.records.where(type: :mx).count assert_equal 1, ops[:additions].size assert_equal 1, ops[:changes].size assert_equal 1, ops[:deletes].size end end class ApiBulkTest < ActiveSupport::TestCase def setup @domain = create(:domain) @a = create(:a, domain: @domain) @aaaa = create(:aaaa, domain: @domain, content: '::42') @new = build(:mx, domain: @domain) @upsert_txt = build(:txt, domain: @domain) end def valid_changes @valid_changes ||= begin {}.tap { |c| c[:deletes] = [@a.to_api] c[:additions] = [@new.to_api] c[:upserts] = [@upsert_txt.to_api] } end end test 'apply changes' do ops, err = @domain.api_bulk valid_changes @domain.reload @aaaa.reload assert_empty err assert_empty @domain.records.where(id: @a.id) assert_equal '::42', @aaaa.content assert_equal 1, @domain.records.where(type: :mx).count assert_equal 2, ops[:additions].size # upsert is accounted as in addition assert_equal 1, ops[:deletes].size end test 'additions is invalid' do changes = { additions: [ @new.to_api.update(prio: -1) ] } ops, err = @domain.api_bulk changes assert_not_empty err assert_includes err[:additions].first[:error], 'not a valid DNS priority' end test 'delete not exists' do changes = Hash[ :deletes, [{name: 'nx', type: 'TXT', content: 'not-exists'}] ] ops, err = @domain.api_bulk changes assert_empty ops assert_equal 1, err[:deletes].size end test 'upsert does not exist (single record)' do a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1') rr_name = "rr.#{@domain.name}" changes = Hash[ :upserts, [{name: rr_name, type: 'A', content: '127.0.0.3'}] ] ops, err = @domain.api_bulk changes assert_empty err assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count assert_equal '127.0.0.3', @domain.records.find_by(name: rr_name, type: 'A').content end test 'upsert does not exist (multiple records)' do a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1') a2 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.2') rr_name = "rr.#{@domain.name}" changes = Hash[ :upserts, [{name: rr_name, type: 'A', content: '127.0.0.3'}] ] ops, err = @domain.api_bulk changes assert_empty err assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count assert_equal '127.0.0.3', @domain.records.find_by(name: rr_name, type: 'A').content end test 'upsert exists' do a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1') rr_name = "rr.#{@domain.name}" changes = Hash[ :upserts, [{name: rr_name, type: 'A', content: '127.0.0.1'}] ] ops, err = @domain.api_bulk changes assert_empty err assert_empty ops # upsert is a noop assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count assert_equal '127.0.0.1', @domain.records.find_by(name: rr_name, type: 'A').content end end end