diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb new file mode 100644 index 0000000..b578d15 --- /dev/null +++ b/app/controllers/api_controller.rb @@ -0,0 +1,82 @@ +class ApiController < ApplicationController + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + rescue_from ActionController::ParameterMissing, with: :parameter_missing + + # This a private trusted API + skip_before_action :verify_authenticity_token + + before_action :authenticate_token + before_action :domain, only: [:list, :bulk] + + # GET /ping + def ping + render json: { ok: true, response: :pong } + end + + # GET /whoami + def whoami + render json: { ok: true, response: current_user.to_api } + end + + # GET domain//list + def list + records = Record.smart_order(@domain.records).map(&:to_api) + render json: { ok: true, response: records } + end + + # POST domain//list + def bulk + api_params = params.require(:api).permit! + ops, err, bulk_ops = domain.api_bulk(api_params) + + if err.empty? + notify_record_bulk(domain, bulk_ops) + + render json: { ok: true, + response: { + operations: ops + } + } + else + render json: { ok: false, + errors: err, + response: { + operations: ops + } + } + end + end + + private + + def authenticate_token + if user = User.find_by_token(params.require(:token)) + warden.set_user(user, store: false) + else + head(403) + end + end + + def domain + if params[:domain] =~ /^[0-9]+$/ + params[:domain_id] = params[:domain] + else + params[:domain_id] = Domain.find_by_name!(params[:domain]).id + end + + super + end + + def record_not_found + render json: { ok: false, error: :record_not_found } + end + + def parameter_missing + render json: { ok: false, error: :parameter_missing } + end + + def notify_record_bulk(*args) + notification.notify_record_bulk(current_user, *args) if WebDNS.settings[:notifications] + end + +end diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb new file mode 100644 index 0000000..301d4e9 --- /dev/null +++ b/app/controllers/help_controller.rb @@ -0,0 +1,7 @@ +class HelpController < ApplicationController + before_action :authenticate_user! + + def api + render layout: false + end +end diff --git a/app/models/domain.rb b/app/models/domain.rb index edad604..2894121 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,321 +1,384 @@ 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].keys.map(&:to_s) 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 belongs_to :dnssec_policy 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? validates :dnssec_policy_id, presence: true, if: :dnssec? after_create :generate_soa after_create :generate_ns after_create :install before_save :check_convert before_save :check_dnssec_parent_authority, if: :dnssec? 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_ds_removal) { |domain, _t| Job.dnssec_drop_ds(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_ds_rollover) { |domain, t| Job.dnssec_rollover_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 transition wait_for_ready: :pending_ds, operational: :pending_ds_rollover end event :plain_convert do transition operational: :pending_plain end event :remove do transition [:operational, :pending_ds_removal] => :pending_remove end event :full_remove do transition operational: :pending_ds_removal end # Machine events event :installed do transition pending_install: :operational end event :converted do transition [:pending_ds, :pending_plain] => :operational end event :complete_rollover do transition pending_ds_rollover: :operational end event :cleaned_up do transition pending_remove: :destroy end event :ksk_rollover_detected do transition operational: :ksk_rollover end end # Returns true if this domain is elegigble for DNSSEC def dnssec_elegible? return false if slave? true end # Returns the zone serial if a SOA record exists def serial return if !soa soa.serial 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 def to_export Hash[ :id, id, :name, name, :group, group.name, ].with_indifferent_access 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) 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 api bulk to operations to the zone + # + # 1) Deletions + # 2) Upserts + # 3) Additions + def api_bulk(opts) + api_deletes = opts[:deletes] || [] + api_upserts = opts[:upserts] || [] + api_additions = opts[:additions] || [] + api_delete_errors = {} + + deletes = [] + additions = {} + + api_deletes.each { |del| + rec = records.find_by(del) + # Fail-fast if record doesn't exist + if rec.nil? + return [{}, { deletes: { del: 'record not found'}}] + end + + deletes << rec.id + } + + # We delete records matching the same name & type + api_upserts.each { |ups| + query = ups.slice(:name, :type) + existing = records.where(query).to_a + + # Skip upsert if we are trying to save the same record + next if existing.one? && ups.all? { |k, v| existing.first.to_api[k] == v } + + deletes += existing.map(&:id) + api_additions << ups + } + + api_additions.each { |add| + additions[add] = add + } + + ops, errors = bulk(deletes: deletes, additions: additions) + + # Serialize the response for API + api_ops = {} + api_errors = {} + + # ops + ops.each { |op, recs| api_ops[op] = recs.map(&:to_api) } + + # errors + if errors.any? + errors.each { |op, err| + api_errors[op] = err.map { |rec, err| + { operation: rec, error: err } + } + } + end + + # This is a bit ugly, we return an ops hash with the original bulk + # responses so we can feed it to record notification. + [api_ops, api_errors, ops] + 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] = {} } operations = 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 operations[:deletes] << rec 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| if rec = to_change[Integer(rec_id)] operations[:changes] << rec 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) operations[:additions] << rec errors[:additions][inc] = rec.errors.full_messages.join(', ') if !rec.save } raise ActiveRecord::Rollback if errors.any? end [operations, 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 check_dnssec_parent_authority cfg = WebDNS.settings[:dnssec_parent_authorities][dnssec_parent_authority.to_sym] return if !cfg[:valid] return true if cfg[:valid].call(dnssec_parent) errors.add(:dnssec_parent_authority, 'Parent zone is not accepted for the selected parent authority!') 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/models/record.rb b/app/models/record.rb index c29fa47..228e4f9 100644 --- a/app/models/record.rb +++ b/app/models/record.rb @@ -1,311 +1,322 @@ 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? } before_validation :guess_reverse_name before_validation :set_name after_save :update_zone_serial after_destroy :update_zone_serial 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, 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 end diff --git a/app/models/user.rb b/app/models/user.rb index dcafc46..b9f1cbe 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,23 +1,30 @@ class User < ActiveRecord::Base devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable has_many :memberships has_many :groups, through: :memberships scope :orphans, -> { includes(:memberships).where(:memberships => { user_id: nil }) } # Check if the user can change his password # # Remote users are not able to change their password def can_change_password? !identifier? end + def to_api + Hash[ + :id, id, + :email, email + ].with_indifferent_access + end + def self.find_for_database_authentication(conditions) # Override devise method for database auth # We only want to auth local user via the database. find_first_by_auth_conditions(conditions, identifier: '') end end diff --git a/app/views/help/api.html.erb b/app/views/help/api.html.erb new file mode 100644 index 0000000..a9b6988 --- /dev/null +++ b/app/views/help/api.html.erb @@ -0,0 +1,104 @@ +WebDNS API +

WebDNS API

+

General

+

To access WebDNS API you must have an API token. If you are a WebDNS user you can generate your token by clicking on the API Token link in the navigation bar.

+ +

Debug API

+

GET /ping

+
curl -X GET https://webdns/api/ping
+{
+  "ok": true,
+  "response": "pong"
+}
+
+

GET /whoami

+
curl -X GET https://webdns/api/whoami
+{
+  "ok": true,
+  "response": {
+    "id": 1,
+    "email": "user@example.com"
+  }
+}
+
+

Records API

+

GET /domain/<name>/list

+
curl -X GET https://webdns/api/domain/example.com/list
+
+{
+  "ok": true,
+  "response": [
+    {
+      "name": "example.com",
+      "content": "ns1.example.com webdns@example.com 2016050301 10800 3600 604800 3600",
+      "type": "SOA",
+      "ttl": null,
+      "prio": null,
+      "disabled": false
+    },
+    {
+      "name": "example.com",
+      "content": "ns1.example.com",
+      "type": "NS",
+      "ttl": null,
+      "prio": null,
+      "disabled": false
+    },
+    {
+      "name": "www.example.com",
+      "content": "192.0.0.1",
+      "type": "A",
+      "ttl": null,
+      "prio": null,
+      "disabled": false
+    },
+    {
+      "name": "www.example.com",
+      "content": "2001:db8::1",
+      "type": "AAAA",
+      "ttl": null,
+      "prio": null,
+      "disabled": false
+    }
+
+

POST /domain/<name>/bulk

+

The bulk API allows multiple operations to be perfomed as a single transactional operation. There a three supported operations and they are applied in the following order:

+ +

additions is an array of hashes. Each hash represents a record to be added. +name, type, content and prio fields are supported.

+

deletes is an array of hashes. Each hash represents a single record to be deleted. The fields musts match exactly one record.

+

upserts is an array of hashes. Each hash represents a records to be added, just like an addition. What’s different about upserts is that, before adding the records, all records matching the hash’s name and type are deleted.

+

Fields

+ +
curl -X POST https://webdns/api/domain/example.com/bulk  -H 'Content-Type: application/json' -d '{
+  "upserts": [
+    {
+      "name": "mail.example.com",
+      "type": "A",
+      "content": "1.2.3.4"
+    }
+  ],
+  "additions": [
+    {
+      "name": "www.example.com",
+      "type": "A",
+      "content": "1.2.3.4"
+    }
+  ]
+}'
+
+ + \ No newline at end of file diff --git a/app/views/help/api.md b/app/views/help/api.md new file mode 100644 index 0000000..fef7ee0 --- /dev/null +++ b/app/views/help/api.md @@ -0,0 +1,115 @@ +# WebDNS API + +## General + +To access WebDNS API you must have an API `token`. If you are a WebDNS user you can generate your token by clicking on the `API Token` link in the navigation bar. + + * All API request should be routed under the `/api/` prefix. + * The API token needs to be present as a `?token` URL parameter in all requests. + * When sending data (POST/PUT) make sure to correctly set the content encoding header (`Content-Enconding: application/json`). + +## Debug API + +### GET `/ping` +```bash +curl -X GET https://webdns/api/ping +{ + "ok": true, + "response": "pong" +} +``` + +### GET `/whoami` +```bash +curl -X GET https://webdns/api/whoami +{ + "ok": true, + "response": { + "id": 1, + "email": "user@example.com" + } +} +``` + +## Records API + +### GET `/domain//list` +```bash +curl -X GET https://webdns/api/domain/example.com/list + +{ + "ok": true, + "response": [ + { + "name": "example.com", + "content": "ns1.example.com webdns@example.com 2016050301 10800 3600 604800 3600", + "type": "SOA", + "ttl": null, + "prio": null, + "disabled": false + }, + { + "name": "example.com", + "content": "ns1.example.com", + "type": "NS", + "ttl": null, + "prio": null, + "disabled": false + }, + { + "name": "www.example.com", + "content": "192.0.0.1", + "type": "A", + "ttl": null, + "prio": null, + "disabled": false + }, + { + "name": "www.example.com", + "content": "2001:db8::1", + "type": "AAAA", + "ttl": null, + "prio": null, + "disabled": false + } +``` + +### POST `/domain//bulk` + +The `bulk` API allows multiple operations to be perfomed as a single transactional operation. There a three supported operations and they are applied in the following order: + + * `deletes` + * `upserts` + * `additions` + +`additions` is an array of hashes. Each hash represents a record to be added. +`name`, `type`, `content` and `prio` fields are supported. + +`deletes` is an array of hashes. Each hash represents a single record to be deleted. The fields musts match **exactly one** record. + +`upserts` is an array of hashes. Each hash represents a records to be added, just like an `addition`. What's different about `upserts` is that, before adding the records, all records matching the hash's `name` and `type` are deleted. + +#### Fields + * `name`: Record name. Should be expanded to contain the full domain name. + * `type`: Capitilized record type (`'A'`, `'AAAA'`, etc) + * `prio`: Record priority, if supported. + * `content`: Record content. When this is a CNANE **do not** include the trailing dot. + +```bash +curl -X POST https://webdns/api/domain/example.com/bulk -H 'Content-Type: application/json' -d '{ + "upserts": [ + { + "name": "mail.example.com", + "type": "A", + "content": "1.2.3.4" + } + ], + "additions": [ + { + "name": "www.example.com", + "type": "A", + "content": "1.2.3.4" + } + ] +}' +``` \ No newline at end of file diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index 2c7b7ff..98af7f0 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -1,56 +1,57 @@ diff --git a/config/initializers/00_settings.rb b/config/initializers/00_settings.rb index 57b321d..5216419 100644 --- a/config/initializers/00_settings.rb +++ b/config/initializers/00_settings.rb @@ -1,48 +1,50 @@ WebDNS.settings[:soa_defaults] = { primary_ns: 'ns1.example.com', contact: 'domainmaster@example.com', serial: 1, refresh: 10_800, retry: 3600, expire: 604_800, nx: 3600 } WebDNS.settings[:default_ns] = [ 'ns1.example.com', 'ns2.example.com' ] WebDNS.settings[:dnssec] = true WebDNS.settings[:dnssec_parent_authorities] = { webdns: { valid: -> (parent) { Domain.find_by_name(parent) } # Check if parent is self-hosted }, papaki: { valid: -> (parent) { parent.split('.').size == 1 } # TLDs } } WebDNS.settings[:dnssec_ds_removal_sleep] = 14400 * 2 # Testing helper WebDNS.settings[:dnssec_parent_authorities].merge!( test_authority: { valid: -> (parent) { true } } ) if Rails.env.test? WebDNS.settings[:serial_strategy] = Strategies::Date WebDNS.settings[:prohibit_records_types] = [] WebDNS.settings[:prohibit_domain_types] = ['NATIVE'] WebDNS.settings[:contact_mail] = 'webdns@example.com' WebDNS.settings[:mail_from] = 'webdns@example.com' WebDNS.settings[:admin_group] = 'admin' WebDNS.settings[:saml] = false WebDNS.settings[:saml_required_entitlement] = 'webdns' WebDNS.settings[:saml_login_text] = 'Login with SAML' +WebDNS.settings[:api] = true + # Allow local overrides local_settings = File.expand_path('../../local_settings.rb', __FILE__) require_relative local_settings if File.exist?(local_settings) diff --git a/config/routes.rb b/config/routes.rb index ca7ed0f..c76f3ab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,69 +1,79 @@ Rails.application.routes.draw do # Override devise user removal devise_scope :users do delete :users, to: redirect('/') end devise_for :users get '/auth/saml', to: 'auth#saml' root to: redirect('/domains') resources :users, only: [] do get :token, to: 'users#token', on: :member post :generate_token, to: 'users#generate_token', on: :member end resources :groups, only: [:show] do get :search_member, to: 'groups#search_member', on: :member post :members, to: 'groups#create_member', as: :create_member, on: :member delete 'member/:user_id', to: 'groups#destroy_member', as: :destroy_member, on: :member end resources :domains do get :edit_dnssec, to: 'domains#edit_dnssec', on: :member delete :full_destroy, to: 'domains#full_destroy', on: :member resources :records, except: [:index, :show] do # Reuse records#update instead of introducing new controller actions # # rubocop:disable Style/AlignHash put :disable, to: 'records#update', on: :member, defaults: { record: { disabled: true } } put :enable, to: 'records#update', on: :member, defaults: { record: { disabled: false } } put :editable, to: 'records#editable', on: :collection post :valid, to: 'records#valid', on: :collection post :bulk, to: 'records#bulk', on: :collection # rubocop:enable Style/AlignHash end end get '/records/search', to: 'records#search' # Admin namespace :admin do root to: redirect('/admin/groups') resources :groups, except: [:show] resources :jobs, only: [:index, :destroy] do put :done, to: 'jobs#update', on: :member, defaults: { job: { status: 1 } } put :pending, to: 'jobs#update', on: :member, defaults: { job: { status: 0 } } end resources :users, only: [:destroy] do get :orphans, to: 'users#orphans', on: :collection put :update_groups, to: 'users#update_groups', on: :collection end end + # API + scope '/api' do + get :ping, to: 'api#ping' + get :whoami, to: 'api#whoami' + get '/domain/:domain/list', to: 'api#list', constraints: { domain: /[^\/]+/} + post '/domain/:domain/bulk', to: 'api#bulk', constraints: { domain: /[^\/]+/} + end if WebDNS.settings[:api] + # Private put 'private/replace_ds', to: 'private#replace_ds' put 'private/trigger_event', to: 'private#trigger_event' get 'private/zones', to: 'private#zones' + + get 'help/api', to: 'help#api' end diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index b91ea82..fce86b1 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -1,308 +1,414 @@ 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) @policy = create(:dnssec_policy) 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