diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index b0577dd..889e8f2 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -1,82 +1,91 @@ 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, except: :ping 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 + # GET domains + def domains + domains = show_domain_scope.includes(:group).all + render json: { + ok: true, + response: domains.map { |d| d.to_api } + } + end + private def authenticate_token if user = User.find_by_token(params.require(:token)) warden.set_user(user, store: false) else render json: { ok: false, error: "invalid-token" } 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/models/domain.rb b/app/models/domain.rb index 2894121..25e4c52 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,384 +1,392 @@ 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 + def to_api + Hash[ + :name, name, + :slave, slave?, + :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/views/help/api.html.erb b/app/views/help/api.html.erb index a9b6988..0d6b879 100644 --- a/app/views/help/api.html.erb +++ b/app/views/help/api.html.erb @@ -1,104 +1,119 @@ -WebDNS API +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

+

Domains API

+

GET /domains

+
curl -X GET https://webdns/api/domains
+
+{       
+  "ok": true,
+  "response": [
+    {
+      "name": "example.com",
+      "slave": false,
+      "group": "group1"
+    }
+   ]
+}
+
+

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

+

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

+

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 index fef7ee0..29e73bb 100644 --- a/app/views/help/api.md +++ b/app/views/help/api.md @@ -1,115 +1,133 @@ # 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" } } ``` +## Domains API + +### GET `/domains` +```bash +curl -X GET https://webdns/api/domains + +{ + "ok": true, + "response": [ + { + "name": "example.com", + "slave": false, + "group": "group1" + } + ] +} +``` + ## 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/config/routes.rb b/config/routes.rb index 291cad9..cb736ad 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,81 +1,82 @@ 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 } } get '/type/:category', to: 'jobs#index', on: :collection, constraints: proc { |req| ['completed', 'pending'].include?(req.params[:category]) } 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: /[^\/]+/} + get :domains, to: 'api#domains' 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