diff --git a/app/assets/javascripts/bulky.coffee b/app/assets/javascripts/bulky.coffee index a3cd8df..ccc7e76 100644 --- a/app/assets/javascripts/bulky.coffee +++ b/app/assets/javascripts/bulky.coffee @@ -1,177 +1,206 @@ class window.Bulky constructor: (valid_record_url, submit_url) -> @enabled = false @deletes = {} @changes = {} @additions = {} @add_counter = 0 @valid_record_url = valid_record_url @submit_url = submit_url @hooked = false @hook() panel_update: () -> added = (a for own a of @additions).length changed = (c for own c of @changes).length deleted = (d for own d of @deletes).length $('#bulk-panel .added').text(added) $('#bulk-panel .changed').text(changed) $('#bulk-panel .deleted').text(deleted) marked_for_delete: (id) -> return @deletes[id] update: (id, attr, value) -> @changes[id] or= {} @changes[id][attr] = value $("#record-#{id}").addClass('modified') @panel_update() add: (rec) -> @add_counter += 1 @additions[@add_counter] = rec @panel_update() @add_counter validate_add: (arr) -> _me = this $.ajax { url: @valid_record_url, type: 'post', dataType: 'json', data: arr, success: (data) -> if data.errors alert(data.errors) return id = _me.add(data.record) _me.render_new(id, data.record) error: () -> alert('There was an error processing your request...') } render_new: (id, rec) -> el = $('#new_records .clone').clone() el.removeClass('clone').removeClass('hidden') el.attr('id', "fresh-#{id}") for attr in ['name', 'ttl', 'type', 'prio', 'content'] if rec[attr] el.find(".#{attr}").text(rec[attr]) el.find('a.js-destroy').data('id', id).data('fresh', true) $('#new_records tbody').append(el) $('#new_records').removeClass('hidden') + render_errors: (errors) -> + if errors.deletes + deleted = ("#record-#{d}" for own d of errors.deletes) + txt = " (#{deleted.length} failed to delete)" + $('#bulk-panel .failed-deleted').text(txt).data(ids:deleted) + if errors.changes + changed = ("#record-#{c}" for own c of errors.changes) + txt = " (#{changed.length} failed to be updated)" + $('#bulk-panel .failed-changed').text(txt).data(ids:changed) + if errors.additions + added = ("#fresh-#{a}" for own a of errors.additions) + txt = " (#{added.length} failed to be added)" + $('#bulk-panel .failed-added').text(txt).data(ids:added) + + render_clear_errors: -> + $('#bulk-panel .failed').text('') + enable: -> return if @enabled $('#bulk-panel').removeClass('hidden') $('#new_record .btn').attr('value', 'Bulk Add') $('#records .js-bulk-hide').hide() @enabled = true disable: -> return if !@enabled $('#bulk-panel').addClass('hidden') $('#inline-record-form .btn').attr('value', 'Add') $('#records .js-bulk-hide').show() @enabled = false commit: -> data = { deletes: id for id, _ of @deletes, - changes: rec for id, rec of @changes when !@deletes[id] , + # changes: rec for id, rec of @changes when !@deletes[id], + changes: @changes, additions: @additions } + @render_clear_errors() _me = this $.ajax { url: @submit_url, type: 'post', data: JSON.stringify(data), dataType: 'json', contentType:'application/json', success: (data) -> console.log data + if data.errors + _me.render_errors(data.errors) + return + alert('Bulk operations successfully committed!') + location.reload() error: () -> alert('There was an error submiting bulk operations') } hook: -> return if @hooked _me = this # Hook bulky buttons $('#js-bulky-activate').click -> _me.enable() $(this).parents('li').remove() $('#js-bulky-cancel').click -> _me.disable() $('#js-bulky-commit').click -> _me.commit() $('#bulk-panel .js-modified-hover').hover \ -> $('.modified').addClass('highlight') , -> $('.modified').removeClass('highlight') + + $('#bulk-panel .failed').hover \ + -> $($(this).data('ids').join(', ')).addClass('highlight') + , + -> $($(this).data('ids').join(', ')).removeClass('highlight') # Hook destroy button $('#records, #new_records').on 'click', 'a.js-destroy', () -> return true if !_me.enabled link = $(this) id = link.data('id') fresh = link.data('fresh') # Drop a newly created record if fresh delete _me.additions[id] link.parents("#fresh-#{id}").remove() # Resurrect a delete record else if link.data('deleted') delete _me.deletes[id] link.data('deleted', false) link.parents('tr').removeClass('danger') link.parents('tr').attr('title', 'Remove') link.find('span').removeClass('glyphicon-plus').addClass('glyphicon-remove') # Delete a record else _me.deletes[id] = true link.data('deleted', true) link.parents('tr').addClass('danger') link.find('abbr').attr('title', 'Undo') link.find('span').removeClass('glyphicon-remove').addClass('glyphicon-plus') _me.panel_update() return false; # Hook add button $('#new_record').submit () -> return true if !_me.enabled _me.validate_add $(this).serializeArray() return false; @hooked = true debug: -> console.log("Bulky, enabled=#{@enabled}") for id, rec of @additions change = for k, v of rec "#{k}: #{v}" console.log("Add #{id}: #{change}") for id, rec of @changes change = for k, v of rec "#{k}: #{v}" console.log("Change #{id}: #{change}") for id, _ of @deletes console.log("Delete #{id}") diff --git a/app/controllers/records_controller.rb b/app/controllers/records_controller.rb index 1a3e056..3e8c091 100644 --- a/app/controllers/records_controller.rb +++ b/app/controllers/records_controller.rb @@ -1,128 +1,133 @@ class RecordsController < ApplicationController before_action :authenticate_user! before_action :domain, except: [:search] before_action :editable_transform_params, only: [:editable] before_action :record, only: [:edit, :update, :editable, :destroy] # GET /records/new def new @record = domain.records.build end # GET /records/1/edit def edit end # POST /records def create @record = domain.records.new(new_record_params) if @record.save notify_record(@record, :create) redirect_to domain, notice: 'Record was successfully created.' else flash[:alert] = 'There were some errors creating the record!' render :new end end # PATCH/PUT /records/1 def update if @record.update(edit_record_params) notify_record(@record, :update) redirect_to domain, notice: 'Record was successfully updated.' else render :edit end end def valid @record = domain.records.new(new_record_params) if @record.valid? response = { record: @record.as_bulky_json, errors: false } render json: response else render json: { errors: @record.errors.full_messages.join(', ') } end end def bulk - render json: { ok: true } + err = @domain.bulk(params) + if err.empty? + render json: { ok: true } + else + render json: { errors: err } + end end def editable @record.assign_attributes(edit_record_params) if @record.valid? if @save @record.save! notify_record(@record, :update) end response = { attribute: @attr, value: @record.read_attribute(@attr), serial: @domain.soa(true).serial, record: @record.as_bulky_json, saved: @save } render json: response else render text: @record.errors[@attr].join(', '), status: 400 end end # DELETE /records/1 def destroy @record.destroy notify_record(@record, :destroy) redirect_to domain, notice: 'Record was successfully destroyed.' end # GET /search def search @records = Record .where(domain: show_domain_scope) .includes(:domain) .search(params[:q]) # scope by domain @records = Record.smart_order(@records) end private # Modify params to use standard Rails patterns def editable_transform_params @attr = params[:name] @save = params[:save] != 'false' params[:record] = { params[:name] => params[:value] } end def edit_record_params if @record.type == 'SOA' permitted = [:contact, :serial, :refresh, :retry, :expire, :nx] else permitted = [:name, :content, :ttl, :prio, :disabled] end params.require(:record).permit(*permitted).tap { |r| r[:drop_privileges] = true if not admin? } end def new_record_params params.require(:record).permit(:name, :content, :ttl, :type, :prio).tap { |r| r[:drop_privileges] = true if not admin? } end def notify_record(*args) notification.notify_record(current_user, *args) if WebDNS.settings[:notifications] end end diff --git a/app/models/domain.rb b/app/models/domain.rb index ac10ef8..4ad9dd7 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,230 +1,277 @@ class Domain < ActiveRecord::Base class NotAChild < StandardError; end self.inheritance_column = :nx # List all supported domain types. def self.domain_types [ 'NATIVE', 'MASTER', 'SLAVE', ] end # List domain types that can be created. def self.allowed_domain_types domain_types - WebDNS.settings[:prohibit_domain_types] end # List parent authorities def self.dnssec_parent_authorities WebDNS.settings[:dnssec_parent_authorities] end # Fire event after transaction commmit # Changing state inside a hook messes things up, # this trick handles that attr_accessor :fire_event belongs_to :group has_many :jobs has_many :records # BUG in bump_serial_trigger has_one :soa, -> { unscope(where: :type).where(type: 'soa') }, class_name: SOA validates :group_id, presence: true validates :name, uniqueness: true, presence: true validates :type, presence: true, inclusion: { in: domain_types } validates :master, presence: true, ipv4: true, if: :slave? validates :dnssec, inclusion: { in: [false] }, unless: :dnssec_elegible? validates :dnssec_parent_authority, inclusion: { in: dnssec_parent_authorities }, if: :dnssec? validates :dnssec_parent, hostname: true, if: :dnssec? after_create :generate_soa after_create :generate_ns after_create :install before_save :check_convert after_commit :after_commit_event attr_writer :serial_strategy def self.dnssec_progress(current_state) progress = [ :pending_signing, # 1/3 :wait_for_ready, # 2/3 :pending_ds] # 3/3 idx = progress.index(current_state.to_sym) return if idx.nil? [idx+1, progress.size].join('/') end state_machine initial: :initial do after_transition(any => :pending_install) { |domain, _t| Job.add_domain(domain) } after_transition(any => :pending_remove) { |domain, _t| Job.shutdown_domain(domain) } after_transition(any => :pending_signing) { |domain, _t| Job.dnssec_sign(domain) } after_transition(any => :wait_for_ready) { |domain, _t| Job.wait_for_ready(domain) } after_transition(any => :pending_ds) { |domain, t| Job.dnssec_push_ds(domain, *t.args) } after_transition(any => :pending_plain) { |domain, _t| Job.convert_to_plain(domain) } after_transition(any => :destroy) { |domain, _t| domain.destroy } # User events event :install do transition initial: :pending_install end event :dnssec_sign do transition operational: :pending_signing end event :signed do transition pending_signing: :wait_for_ready end event :push_ds do # TODO: push_ds is triggered on multiple occasions # operational: :operational transition wait_for_ready: :pending_ds end event :plain_convert do transition operational: :pending_plain end event :remove do transition operational: :pending_remove end # Machine events event :installed do transition pending_install: :operational end event :converted do transition [:pending_ds, :pending_plain] => :operational end event :cleaned_up do transition pending_remove: :destroy end end # Returns true if this domain is elegigble for DNSSEC def dnssec_elegible? return false if slave? true end # Get the zone's serial strategy. # # Returns one of the supported serial strategies. def serial_strategy @serial_strategy ||= WebDNS.settings[:serial_strategy] end # Returns true if this a reverse zone. def reverse? name.end_with?('.in-addr.arpa') || name.end_with?('.ip6.arpa') end # Returns true if this a ENUM zone. def enum? name.end_with?('.e164.arpa') end # Returns true if this is a slave zone. def slave? type == 'SLAVE' end # Compute subnet for reverse records def subnet return if not reverse? if name.end_with?('.in-addr.arpa') subnet_v4 elsif name.end_with?('.ip6.arpa') subnet_v6 end end def self.replace_ds(parent, child, records) parent = find_by_name!(parent) fail NotAChild if not child.end_with?(parent.name) existing = parent.records.where(name: child, type: 'DS') recs = records.map { |rec| DS.new(domain: parent, name: child, content: rec) } ActiveRecord::Base.transaction do existing.destroy_all recs.map(&:save!) end end + # Apply bulk to operations to the zones + # + # 1) Deletions + # 2) Changes + # 3) Additions + def bulk(opts) + deletes = opts[:deletes] || [] + changes = opts[:changes] || {} + additions = opts[:additions] || {} + errors = Hash.new { |h, k| h[k] = {} } + + ActiveRecord::Base.transaction do + # Deletes + to_delete = records.where(id: deletes).index_by(&:id) + deletes.each { |rec_id| + if rec = to_delete[Integer(rec_id)] + rec.destroy + next + end + + errors[:deletes][rec_id] = 'Deleted record not found' + } + + # Changes + to_change = records.where(id: changes.keys).index_by(&:id) + changes.each {|rec_id, changes| + binding + if rec = to_change[Integer(rec_id)] + errors[:changes][rec_id] = rec.errors.full_messages.join(', ') if !rec.update(changes) + next + end + + errors[:changes][rec_id] = 'Changed record not found' + } + + # Additions + additions.each { |inc, attrs| + rec = records.new(attrs) + errors[:additions][inc] = rec.errors.full_messages.join(', ') if !rec.save + } + + raise ActiveRecord::Rollback if errors.any? + end + + errors + end + private def subnet_v4 # get ip octets (remove .in-addr.arpa) octets = name.split('.')[0...-2].reverse return if octets.any? { |_| false } mask = 8 * octets.size octets += [0, 0, 0, 0] ip = IPAddr.new octets[0, 4].join('.') [ip, mask].join('/') end def subnet_v6 nibbles = name.split('.')[0...-2].reverse return if nibbles.any? { |_| false } mask = 4 * nibbles.size nibbles += [0] * 32 ip = IPAddr.new nibbles[0, 32].in_groups_of(4).map(&:join).join(':') [ip, mask].join('/') end # Hooks def generate_soa soa_record = SOA.new(domain: self) soa_record.save! end def generate_ns return if slave? return if WebDNS.settings[:default_ns].empty? WebDNS.settings[:default_ns].each { |ns| Record.find_or_create_by!(domain: self, type: 'NS', name: '', content: ns) } end def check_convert return if !dnssec_changed? event = dnssec ? :dnssec_sign : :plain_convert if state_events.include?(event) self.fire_event = event # Schedule event for after commit return true end errors.add(:dnssec, 'You cannot modify dnssec settings in this state!') false end def after_commit_event return if !fire_event fire_state_event(fire_event) self.fire_event = nil end end diff --git a/app/views/domains/_bulk_panel.html.erb b/app/views/domains/_bulk_panel.html.erb index a465234..cfd7b35 100644 --- a/app/views/domains/_bulk_panel.html.erb +++ b/app/views/domains/_bulk_panel.html.erb @@ -1,16 +1,18 @@ diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index 0e2cf0d..ef79d41 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -1,169 +1,220 @@ 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 '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 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) 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 assert @domain.dnssec_sign # user triggered 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]) # DS script triggered assert_equal 'pending_ds', @domain.state end assert @domain.converted # 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 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 '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 + 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 + 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 + end + end end