diff --git a/.gitignore b/.gitignore index 91ddf1f..6ec616c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,23 @@ # See https://help.github.com/articles/ignoring-files for more about ignoring files. # # If you find yourself ignoring temporary files generated by your text editor # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile '~/.gitignore_global' # Ignore bundler config. /.bundle # Ignore the default SQLite database. /db/*.sqlite3 /db/*.sqlite3-journal /db/*.local /db/database.yml # Ignore all logfiles and tempfiles. /log/*.log /tmp +/doc *.swp config/database.yml config/beanstalk.yml .ruby-version diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e3f459 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# WebDNS + +`webdns` is a web PowerDNS frontend using the powerful pdns MySQL backend. + +## Features + + * Automatically handle zone serial updates using multiple serial strategies. + * DNSSEC support. + * Slave & Master support. + * Smart editing helpers. + * A flexible per group permission model. + +## Installation + +`webdns` was developed to be deployed using standard debian jessie (stable) packages and practices. It does not require bundler for production, but is should be pretty straightforward to set it up using bundler on other platforms. + +### Setup powerdns +``` +$ sudo apt-get install pdns-server pdns-backend-mysql +$ cat /etc/powerdns/pdns.d/pdns.local.gmysql.conf +# MySQL Configuration +launch+=gmysql + +# gmysql parameters +gmysql-host=127.0.0.1 +gmysql-port=3306 +gmysql-dbname=webdns +gmysql-user=webdns +gmysql-password=password +gmysql-dnssec=no +``` + +You might also want to enable slave and master support on powerdns in order to manage slave & master zones in WebDNS. + +### Prepare for deploy + +Install dependencies + +``` +$ cat Gemfile | grep -oE 'pkg:[a-z0-9-]+' | cut -d: -f2 | xargs echo apt-get install +``` + +Edit & install + +``` +contrib/systemd/sudo_unicorn -> /etc/sudoers.d/unicorn +contrib/systemd/default_unicorn -> /etc/default/unicorn +contrib/systemd/unicornctl -> /usr/local/bin/unicornctl +contrib/systemd/unicorn.service -> /etc/systemd/system/unicorn.service +``` + +``` +systemctl daemon-reload # Notify systemd about the newly installed unicorn service +``` + +You should also create an empty database and an account. + +### Setup Capistrano + +On the server create the following files under `/srv/webdns/shared/config/`. Those files will be symlinked to `config/` on deploy by capistrano. + +``` +database.yml +secrets.yml +local_settings.rb # Override config/initializers/00_settings.rb + +``` +Locally create `config/deploy/production.rb` based on the sample file. + +``` +$ apt get install capistrano +$ cap production deploy + +``` diff --git a/README.rdoc b/README.rdoc deleted file mode 100644 index dd4e97e..0000000 --- a/README.rdoc +++ /dev/null @@ -1,28 +0,0 @@ -== README - -This README would normally document whatever steps are necessary to get the -application up and running. - -Things you may want to cover: - -* Ruby version - -* System dependencies - -* Configuration - -* Database creation - -* Database initialization - -* How to run the test suite - -* Services (job queues, cache servers, search engines, etc.) - -* Deployment instructions - -* ... - - -Please feel free to use a different markup language if you do not plan to run -rake doc:app. diff --git a/app/helpers/flash_helper.rb b/app/helpers/flash_helper.rb index a1b2085..5a50542 100644 --- a/app/helpers/flash_helper.rb +++ b/app/helpers/flash_helper.rb @@ -1,26 +1,27 @@ module FlashHelper + # Generate html flash messages based on bootstrap alerts. def flash_messages flash.each do |msg_type, message| concat( content_tag(:div, message, class: "alert #{bootstrap_class_for(msg_type)} fade in") do concat content_tag(:button, 'x', class: 'close', data: { dismiss: 'alert' }) concat message end ) end nil end private def bootstrap_class_for(flash_type) { success: 'alert-success', error: 'alert-danger', alert: 'alert-warning', notice: 'alert-info' }.fetch(flash_type.to_sym, flash_type.to_s) end end diff --git a/app/helpers/records_helper.rb b/app/helpers/records_helper.rb index 98283f0..9ea218e 100644 --- a/app/helpers/records_helper.rb +++ b/app/helpers/records_helper.rb @@ -1,8 +1,14 @@ module RecordsHelper + # Smart suffix for records + # + # On forward zones returns the zone name. + # On reverse zones returns the zone name but also tries to infer the subnet. + # + # Returns a smart suffix string. def name_field_append(record) return ".#{record.domain.name}" if not record.domain.reverse? ".#{record.domain.name} (#{record.domain.subnet})" end end diff --git a/app/models/domain.rb b/app/models/domain.rb index 6ab3f0c..2e2a7fc 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,92 +1,99 @@ class Domain < ActiveRecord::Base self.inheritance_column = :nx + # List all supported domain types. 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 after_create :generate_ns attr_writer :serial_strategy + + # 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 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 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 end diff --git a/app/models/record.rb b/app/models/record.rb index 3e3f1a6..26f9b1b 100644 --- a/app/models/record.rb +++ b/app/models/record.rb @@ -1,161 +1,198 @@ 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' ] 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'] 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? } before_validation :guess_reverse_name before_validation :set_name after_save :update_zone_serial after_destroy :update_zone_serial - - # Smart order a list of domains + # 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.name, r.type == 'SOA' ? 0 : 1, r.type == 'NS' ? 0 : 1, record_types.index(r.type), # Friendly type r.prio, r.content ] } 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? 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 - # Create record specific urls for all record types + # Make sure rails generates record specific urls for all record types. # - # Overrides default rails STI + # 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 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 end diff --git a/lib/bean/client.rb b/lib/bean/client.rb index cbcb04f..136dd24 100644 --- a/lib/bean/client.rb +++ b/lib/bean/client.rb @@ -1,41 +1,52 @@ module Bean class Client + + # Initialize a new Bean::Client. + # + # host - The host to connect to. + # def initialize(host) @host = host end + # Put a job in the default beanstalk tube. def put(body) client.tubes['default'].put(body) rescue Beaneater::NotConnected reconnect! end + # Get a job from the dafault beanstalk tube. def reserve(*args) client.tubes.reserve(*args) end + # Reconnect to the beanstalk server. + # + # retries - Number of retries before failing. + # sleep_time - Time between retries. def reconnect!(retries = 3, sleep_time = 0.5) client! rescue Beaneater::NotConnected => exception retries -= 1 raise exception if retries.zero? sleep(sleep_time) retry end private def client @client ||= client! end def client! @client.close if @client # rescue nil @client = connect end def connect Beaneater.new(@host) end end end diff --git a/lib/bean/worker.rb b/lib/bean/worker.rb index fa09118..61e7dd6 100644 --- a/lib/bean/worker.rb +++ b/lib/bean/worker.rb @@ -1,70 +1,77 @@ require 'singleton' module Bean class Worker include Singleton TIMEOUT = 5 attr_accessor :job + # Start consuming jobs. def self.work instance.work end + # Start consuming jobs. + # + # Handles reconnects. def work register_signals watch rescue Beaneater::NotConnected Base.beanstalk_reconnect! end + # Graceful stop the worker. + # + # If no job is running stops immediately. def stop if job.nil? exit else @stop = true end end private def stop? # rubocop:disable Style/TrivialAccessors @stop end def register_signals trap('INT') { stop } trap('TERM') { stop } end def watch loop do procline('watching') break if stop? process_job end rescue Beaneater::TimedOutError retry end def process_job self.job = Base.bean.reserve(TIMEOUT) log_job job.delete ensure self.job = nil end def log_job procline("working on jobid=#{job.id} #{job.body}") Rails.logger.warn(job_id: job.id, job_body: job.body.to_s) end def procline(line) $0 = "bean-#{line}" end end end diff --git a/lib/drop_privileges_validator.rb b/lib/drop_privileges_validator.rb index f735ee1..4a69826 100644 --- a/lib/drop_privileges_validator.rb +++ b/lib/drop_privileges_validator.rb @@ -1,27 +1,28 @@ module ActiveModel module Validations class DropPrivilegesValidator < EachValidator def initialize(options) super setup!(options[:class]) end + # Add an error on the specified attribute if we are on drop privileges mode. def validate_each(record, attribute, _value) record.errors.add(attribute, options[:message]) if record.drop_privileges end private def setup!(klass) klass.send(:attr_reader, :drop_privileges) unless klass.method_defined?(:drop_privileges) klass.send(:attr_writer, :drop_privileges) unless klass.method_defined?(:drop_privileges=) end end module HelperMethods def validates_drop_privileges(*attr_names) validates_with DropPrivilegesValidator, _merge_attributes(attr_names) end end end end diff --git a/lib/ipv4_validator.rb b/lib/ipv4_validator.rb index 55b4165..67e42ca 100644 --- a/lib/ipv4_validator.rb +++ b/lib/ipv4_validator.rb @@ -1,17 +1,19 @@ require 'ipaddr' require 'socket' class Ipv4Validator < ActiveModel::EachValidator + # Returns true if addr is a valid IPv4 address. def valid_v4?(addr) return false if addr['/'] # IPAddr accepts addr/mask format IPAddr.new(addr, Socket::AF_INET) true rescue IPAddr::AddressFamilyError, IPAddr::InvalidAddressError false end + # Add an attribute error if this is not a valid IPv4 address. def validate_each(record, attribute, value) return if valid_v4?(value) record.errors[attribute] << 'is not a valid IPv4 address' end end diff --git a/lib/ipv6_validator.rb b/lib/ipv6_validator.rb index 09780c3..cdd1876 100644 --- a/lib/ipv6_validator.rb +++ b/lib/ipv6_validator.rb @@ -1,21 +1,22 @@ require 'ipaddr' require 'socket' class Ipv6Validator < ActiveModel::EachValidator - + # Returns true if addr is valid IPv6 address. def valid_v6?(addr) return false if addr['/'] # IPAddr accepts addr/mask format return false if addr['['] return false if addr[']'] IPAddr.new(addr, Socket::AF_INET6) true rescue IPAddr::AddressFamilyError, IPAddr::InvalidAddressError false end + # Add an attribute error if this is not a valid IPv4 address. def validate_each(record, attribute, value) return if valid_v6?(value) record.errors[attribute] << 'is not a valid IPv6 address' end end diff --git a/lib/notification.rb b/lib/notification.rb index 43c401e..049be51 100644 --- a/lib/notification.rb +++ b/lib/notification.rb @@ -1,93 +1,93 @@ require 'singleton' class Notification include Singleton - # Send out a notification about notable record changes + # Send out a notification about notable record changes. def notify_record(user, record, context) ActiveSupport::Notifications.instrument( 'webdns.record', user: user, context: context, object: record) end - # Send out a notification about notable domain changes + # Send out a notification about notable domain changes. def notify_domain(user, domain, context) ActiveSupport::Notifications.instrument( 'webdns.domain', user: user, context: context, object: domain) end - # Subscribe to domain/record notifications + # Subscribe to domain/record notifications. def hook hook_record hook_domain end private def hook_record ActiveSupport::Notifications .subscribe 'webdns.record' do |_name, _started, _finished, _unique_id, data| handle_record(data) end end def hook_domain ActiveSupport::Notifications .subscribe 'webdns.domain' do |_name, _started, _finished, _unique_id, data| handle_domain(data) end end def handle_record(data) record, context, user = data.values_at(:object, :context, :user) domain = record.domain changes = record.previous_changes # Nobody is interested in those changes.delete('updated_at') changes.delete('created_at') return if changes.empty? others = domain.group.users.where.not(id: user.id).pluck(:email) return if others.empty? admin_action = !user.groups.exists?(domain.group_id) NotificationMailer.notify_record( record: record, context: context, user: user, admin: admin_action, others: others, changes: changes ).deliver end def handle_domain(data) domain, context, user = data.values_at(:object, :context, :user) changes = domain.previous_changes # Nobody is interested in those changes.delete('updated_at') changes.delete('created_at') return if changes.empty? others = domain.group.users.where.not(id: user.id).pluck(:email) return if others.empty? admin_action = !user.groups.exists?(domain.group_id) NotificationMailer.notify_domain( domain: domain, context: context, user: user, admin: admin_action, others: others, changes: changes ).deliver end end diff --git a/lib/prio_validator.rb b/lib/prio_validator.rb index f2395b6..cd96596 100644 --- a/lib/prio_validator.rb +++ b/lib/prio_validator.rb @@ -1,18 +1,19 @@ -# Prio should be between [0, 65535] +# Validates DNS priorities [0, 65535] class PrioValidator < ActiveModel::EachValidator + # Adds an attribute error if value is not a valid DNS priority. def validate_each(record, attribute, value) # Rails autocasts integer fields to 0 if a non-numerical value is passed # we override that by using th *_before_type_cast helper method before_cast = :"#{attribute}_before_type_cast" raw_value = record.send(before_cast) if record.respond_to?(before_cast) raw_value ||= value val = Integer(raw_value) if val < 0 || val > 65_535 record.errors[attribute] << 'is not a valid DNS priority [0, 65535]' end rescue ArgumentError, TypeError record.errors[attribute] << 'is not a valid DNS priority [0, 65535]' end end diff --git a/lib/strategies/date.rb b/lib/strategies/date.rb index ed4e145..8031d79 100644 --- a/lib/strategies/date.rb +++ b/lib/strategies/date.rb @@ -1,19 +1,29 @@ module Strategies module Date module_function + # Generate a new date based serial. + # + # A serial is generate based on the current date where 2 digits + # are held as a counter for changes that happen in the same date. + # We also make sure that the new serial is larger than the previous + # one. + # + # current_serial - The current zone zone serial. + # + # Returns the new serial. def generate_serial(current_serial) # Optimization for the case that current_serial is a lot larger # than the generated serial new = [ Time.now.strftime('%Y%m%d00').to_i, current_serial ].max # Increment until we find a spot new += 1 while new <= current_serial new end end end diff --git a/lib/strategies/incremental.rb b/lib/strategies/incremental.rb index 75a4772..56ba86e 100644 --- a/lib/strategies/incremental.rb +++ b/lib/strategies/incremental.rb @@ -1,9 +1,12 @@ module Strategies module Incremental module_function + # Generate a new incremental serial for the zone. + # + # Returns the new serial. def generate_serial(current_serial) current_serial + 1 end end end