Page MenuHomeGRNET

No OneTemporary

File Metadata

Created
Sat, Mar 21, 11:24 AM
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 @@
<div id="bulk-panel" class="panel panel-primary hidden">
<div class="panel-heading">
Bulk Mode
</div>
<div class="panel-body">
<p>
- <span class="added">0</span> records added,
- <abbr class="js-modified-hover" title="Highlight modified records"><span class="changed">0</span> records modified</abbr>,
- <span class="deleted">0</span> records deleted.
+ <span class="added">0</span> records added<abbr title="Highlight records that failed to be added" class="failed failed-added"></abbr>,
+ <abbr class="js-modified-hover" title="Highlight modified records">
+ <span class="changed">0</span> records modified
+ </abbr><abbr title="Highlight records that failed to update." class="failed failed-changed"></abbr>,
+ <span class="deleted">0</span> records deleted<abbr title="Highlight records that failed to delete" class="failed failed-deleted"></abbr>.
</p>
<div>
<a href="." class="btn btn-default">Cancel (Refresh)</a>
<button type="button" class="btn btn-success" id="js-bulky-commit">Commit</button>
</div>
</div>
</div>
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

Event Timeline