diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 67b4574..313e5f8 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,49 +1,58 @@ // This is a manifest file that'll be compiled into application.js, which will include all the files // listed below. // // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. // // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the // compiled file. // // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details // about supported directives. // //= require jquery.min //= require jquery_ujs //= require bootstrap.min //= require typeahead.bundle.min //= require_tree . $(function() { // Show priority on MX/SRV record only $('#record_type').change(function() { if ($(this).val() == 'MX' || $(this).val() == 'SRV') { $('#record_prio').parents('div.form-group').removeClass('hidden'); } else { $('#record_prio').parents('div.form-group').addClass('hidden'); } }); + // Show master only on SLAVE domains + $('#domain_type').change(function() { + if ($(this).val() == 'SLAVE') { + $('#domain_master').parents('div.form-group').removeClass('hidden'); + } else { + $('#domain_master').parents('div.form-group').addClass('hidden'); + } + }); + var searchMembersGroup = $('#js-search-member').data('group'); var searchMembers = new Bloodhound({ datumTokenizer: Bloodhound.tokenizers.obj.whitespace('email'), queryTokenizer: Bloodhound.tokenizers.whitespace, identify: function(obj) { return obj.id; }, remote: { url: '/groups/' + searchMembersGroup + '/search_member.json?q=%QUERY', wildcard: '%QUERY' } }); $('#js-search-member').typeahead({ hint: true, minLength: 2 }, { name: 'members', display: 'email', source: searchMembers }); }); diff --git a/app/controllers/domains_controller.rb b/app/controllers/domains_controller.rb index 13d2bae..6eaeeaf 100644 --- a/app/controllers/domains_controller.rb +++ b/app/controllers/domains_controller.rb @@ -1,67 +1,67 @@ class DomainsController < ApplicationController before_action :authenticate_user! before_action :group_scope before_action :domain, only: [:show, :edit, :update, :destroy] before_action :group, only: [:show, :edit, :update, :destroy] # GET /domains def index @domains = domain_scope.all end # GET /domains/1 def show @record = Record.new(domain_id: @domain.id) end # GET /domains/new def new @domain = Domain.new end # GET /domains/1/edit def edit end # POST /domains def create @domain = Domain.new(domain_params) if @domain.save redirect_to @domain, notice: "#{@domain.name} was successfully created." else render :new end end # PATCH/PUT /domains/1 def update if @domain.update(domain_params) redirect_to @domain, notice: "#{@domain.name} was successfully updated." else render :edit end end # DELETE /domains/1 def destroy @domain.destroy redirect_to domains_url, notice: "#{@domain.name} was successfully destroyed." end private def group domain.group end def domain_params params.require(:domain).tap { |d| # Make sure group id is permitted (belongs to group_scope) d[:group_id] = group_scope.find_by_id(d[:group_id]).try(:id) - }.permit(:name, :type, :group_id) + }.permit(:name, :type, :master, :group_id) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7bc03bb..f356432 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,57 +1,57 @@ module ApplicationHelper TIME_PERIODS = { 1.second => 'second', 1.minute => 'minute', 1.hour => 'hour', 1.day => 'day', 1.week => 'week', 1.month => 'month', 1.year => 'year', } def can_edit?(object) - return true if admin? return true unless object.respond_to?(:editable?) + by = admin? ? :admin : :user - object.editable? + object.editable?(by) end def seconds_to_human(seconds) acc = {} remaining = seconds TIME_PERIODS.to_a.reverse_each do |p, human| period_count, remaining = remaining.divmod(p) acc[human] = period_count if not period_count.zero? end acc.map { |singular, count| human = count < 2 ? singular : "#{singular}s" "#{count} #{human}" }.join(', ') end def link_to_edit(*args, &block) link_to(abbr_glyph(:pencil, 'Edit'), *args, &block) end def link_to_destroy(*args, &block) link_to(abbr_glyph(:remove, 'Remove'), *args, &block) end def link_to_enable(*args, &block) link_to(abbr_glyph(:'eye-close', 'Enable'), *args, &block) end def link_to_disable(*args, &block) link_to(abbr_glyph(:'eye-open', 'Disable'), *args, &block) end def glyph(icon) content_tag(:span, '', class: "glyphicon glyphicon-#{icon}") end def abbr_glyph(icon, title) content_tag(:abbr, glyph(icon), title: title) end end diff --git a/app/models/domain.rb b/app/models/domain.rb index 1f69578..611167d 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,76 +1,81 @@ class Domain < ActiveRecord::Base self.inheritance_column = :nx def self.domain_types [ 'NATIVE', 'MASTER', 'SLAVE', ] end belongs_to :group has_many :records has_one :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? after_create :generate_soa attr_writer :serial_strategy def serial_strategy @serial_strategy ||= WebDNS.settings[:serial_strategy] end def reverse? name.end_with?('.in-addr.arpa') || name.end_with?('.ip6.arpa') end + 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 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 end diff --git a/app/models/record.rb b/app/models/record.rb index dd35396..47efd58 100644 --- a/app/models/record.rb +++ b/app/models/record.rb @@ -1,119 +1,140 @@ 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) } def self.record_types [ 'A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SPF', 'SRV', 'SSHFP', 'SOA', 'NS', 'PTR', ] end def self.forward_records record_types - ['SOA', 'PTR'] end def self.reverse_records ['PTR', 'CNAME', 'TXT', 'NS'] end 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? } before_validation :guess_reverse_name before_validation :set_name after_save :update_zone_serial after_destroy :update_zone_serial def short return '' if name == domain.name return '' if name.blank? File.basename(name, ".#{domain.name}") end def domain_record? name.blank? || name == domain.name end - # Editable by a non-admin user - def editable? - return false unless Record.allowed_record_types.include?(type) - return false if type == 'NS' && domain_record? + def editable?(by = :user) + return false if domain.slave? + + case by + when :user + return false unless Record.allowed_record_types.include?(type) + return false if type == 'NS' && domain_record? + end true end def supports_prio? false end # Create record specific urls for all record types # # Overrides default rails STI def self.model_name return super if self == Record Record.model_name end def to_dns [name, ttl, 'IN', type, supports_prio? ? prio : nil, content].compact.join(' ') 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' domain.soa.bump_serial! end end diff --git a/app/views/domains/_form.html.erb b/app/views/domains/_form.html.erb index 30a9cfa..914c298 100644 --- a/app/views/domains/_form.html.erb +++ b/app/views/domains/_form.html.erb @@ -1,6 +1,7 @@ <%= bootstrap_form_for(@domain, layout: :horizontal, label_col: 'col-sm-2', control_col: 'col-sm-4') do |f| %> <%= f.text_field :name %> <%= f.collection_select :group_id, @group_scope, :id, :name %> <%= f.select :type, Domain.domain_types %> + <%= f.text_field :master, wrapper_class: 'hidden' %> <%= f.submit 'Save', class: 'btn btn-primary col-sm-offset-2' %> <% end %> diff --git a/app/views/domains/index.html.erb b/app/views/domains/index.html.erb index ca04204..b9e32cb 100644 --- a/app/views/domains/index.html.erb +++ b/app/views/domains/index.html.erb @@ -1,31 +1,34 @@ <% @domains.group_by(&:group).each do |group, domains| %> <% domains.each do |domain| %> <% end %> <% end %>
<%= link_to group.name, group_path(group) %> Controls
<%= link_to domain.name, domain %> <% if domain.reverse? %> <%= abbr_glyph('chevron-left', 'Reverse') %> <% else %> <%= abbr_glyph('chevron-right', 'Forward') %> <% end %> + <% if domain.slave? %> + <%= abbr_glyph('link', 'Slave') %> + <% end %> <%= link_to_edit edit_domain_path(domain) %> <%= link_to_destroy domain, method: :delete, data: { confirm: 'Are you sure?' } %>

<%= link_to 'New Domain »'.html_safe, new_domain_path, class: 'btn btn-lg btn-primary' %>

diff --git a/app/views/domains/show.html.erb b/app/views/domains/show.html.erb index f95db1c..d6f46e0 100644 --- a/app/views/domains/show.html.erb +++ b/app/views/domains/show.html.erb @@ -1,44 +1,44 @@ - + <% @domain.records.each do |record| %> <% if can_edit?(record) %> <% else %> <% end %>
RecordsControls<%= 'Controls' if !@domain.slave? %>
<%= record.name %> <%= record.ttl %> IN <%= record.type %> <%= record.supports_prio? ? record.prio : '' %> <%= record.content %> <% if record.disabled? %> <%= link_to_enable enable_domain_record_path(@domain, record), method: :put %> <% else %> <%= link_to_disable disable_domain_record_path(@domain, record), method: :put %> <% end %> <%= link_to_edit edit_domain_record_path(@domain, record) if can_edit?(record) %> <%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?' } %> <% end %>

<%= link_to 'Add Record', '#new_record', class: 'btn btn-primary', onclick: '$("#new_record_wrapper").toggleClass("hidden");' %>

diff --git a/test/factories/domain.rb b/test/factories/domain.rb index e69573d..6f74dd5 100644 --- a/test/factories/domain.rb +++ b/test/factories/domain.rb @@ -1,24 +1,29 @@ FactoryGirl.define do sequence(:domain) { |n| "example#{n}.com" } factory :domain do group name { generate(:domain) } serial_strategy Strategies::Date type 'NATIVE' end + factory :slave, parent: :domain do + type 'SLAVE' + master '1.2.3.4' + end + factory :date_domain, class: Domain do group name { generate(:domain) } serial_strategy Strategies::Date type 'NATIVE' end factory :v4_arpa_domain, parent: :domain do name '2.0.192.in-addr.arpa' end factory :v6_arpa_domain, parent: :domain do name '8.b.d.0.1.0.0.2.ip6.arpa' end end diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index 986fbc4..6c8b2a0 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -1,47 +1,81 @@ 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 assert_equal 1, @domain.soa.serial 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 '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 end