├── .circleci └── config.yml ├── .gitignore ├── .ruby-version ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── hooks │ └── pre-receive ├── init └── run.rb ├── lib ├── backend.rb ├── backend │ ├── base.rb │ ├── bind.rb │ ├── schema.sql │ └── sqlite.rb ├── debug_log.rb ├── environment.rb ├── example │ ├── .gitignore │ ├── config.yaml │ ├── templates │ │ └── example-dns.rb │ └── zones │ │ └── example.com.rb ├── hash_ext.rb ├── zone.rb └── zone_generator.rb └── test ├── fixtures └── on-client │ ├── onupdate.sh │ ├── templates │ └── ns.rb │ └── zones │ ├── 0.0.127.in-addr.arpa.rb │ ├── example.com.rb │ └── example.org.rb ├── integration ├── bind_test.rb └── sqlite_test.rb ├── test_helper.rb └── unit └── zone_test.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | ruby27: &base 4 | docker: 5 | - image: circleci/ruby:2.7 6 | steps: 7 | - checkout 8 | - run: bundle install --jobs=4 --retry=3 9 | - run: bundle exec rake test 10 | ruby26: 11 | <<: *base 12 | docker: 13 | - image: circleci/ruby:2.6 14 | ruby25: 15 | <<: *base 16 | docker: 17 | - image: circleci/ruby:2.5 18 | ruby24: 19 | <<: *base 20 | docker: 21 | - image: circleci/ruby:2.4 22 | 23 | workflows: 24 | version: 2 25 | build: 26 | jobs: 27 | - ruby27 28 | - ruby26 29 | - ruby25 30 | - ruby24 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.project 2 | /.bundle 3 | /data 4 | /tmp 5 | /vendor 6 | Gemfile.lock 7 | Gemfile.local 8 | .ruby-gemset 9 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.6 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "zonefile", ">= 2.2.3", "< 3.0", git: "https://github.com/digineo/zonefile.git" 4 | 5 | group :sqlite do 6 | gem "sqlite3" 7 | end 8 | 9 | group :test do 10 | gem "rake" 11 | gem "minitest" 12 | end 13 | 14 | local_gemfile = 'Gemfile.local' 15 | if File.exist?(local_gemfile) 16 | eval(File.read(local_gemfile)) # rubocop:disable Lint/Eval 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (C) 2013 Digineo GmbH, Germany 4 | http://www.digineo.de/ 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to 8 | deal in the Software without restriction, including without limitation the 9 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 10 | sell copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 22 | IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DNS Git 2 | 3 | [![CircleCI](https://circleci.com/gh/digineo/dnsgit.svg?style=svg)](https://circleci.com/gh/digineo/dnsgit) 4 | 5 | Run your own DNS servers and manage your zones easily with Git. 6 | 7 | This piece of free software gives you the ability to describe your zone 8 | files in a **simple DSL** (Domain Specific Language) with **templates** 9 | and store everything in a **Git repository**. 10 | 11 | Every time you push your changes, a hook generates all zone files and, 12 | if necessary, increases serial numbers. This has been inspired by 13 | [LuaDNS](http://www.luadns.com/). 14 | 15 | 16 | ## Pre-requisites 17 | 18 | DNS Git has been tested with version 4.1.1 of the 19 | [PowerDNS Authoritative Server](https://www.powerdns.com/). 20 | 21 | DNS Git supports two PowerDNS backends: BIND and SQLite3. 22 | 23 | You need to have Git and a recent version of Ruby (>= v2.4) installed on 24 | your server. If you want to use the SQLite backend, you'll also need 25 | development packages for Ruby and libsqlite3, plus a C compiler (on 26 | Debian-based OS, `ruby-dev`, `libsqlite3-dev` and `build-essential` should 27 | suffice). 28 | 29 | 30 | ## Installation 31 | 32 | First, clone the repository (on the machine your PowerDNS server runs on): 33 | 34 | ```console 35 | $ ssh root@yourserver.example.com 36 | # git clone git://github.com/digineo/dnsgit /opt/dnsgit 37 | # cd /opt/dnsgit 38 | ``` 39 | 40 | Then install the required libraries using bundler. 41 | 42 | Depending on whether or not you have PowerDNS configured with 43 | `launch=bind` or `launch=gsqlite3`, you need to execute one of these 44 | commands: 45 | 46 | ```console 47 | # bundle install --without=sqlite 48 | # bundle install --with=sqlite 49 | ``` 50 | 51 | Finally, initialize a sample configuration repository: 52 | 53 | ```console 54 | # bin/init 55 | Please clone and update the configuration: 56 | git clone root@yourserver.example.com:/opt/dnsgit/data dns-config 57 | ``` 58 | 59 | 60 | ## Configuration 61 | 62 | Run these steps locally on your own machine: 63 | 64 | ```console 65 | $ git clone root@yourserver.example.com:/opt/dnsgit/data dns-config 66 | $ cd dns-config 67 | ``` 68 | 69 | The first thing you should do after setup is modify the contained 70 | `config.yml` and update the values according to your PowerDNS 71 | installation (remove `sqlite:` section for `launch=bind` or remove 72 | `bind:` section for `launch=gsqlite3`). 73 | 74 | Once that's done, you can update the zones. 75 | 76 | Then push your changes back to the server. 77 | 78 | ```console 79 | $ git add -A 80 | $ git commit -m "my commit message" 81 | $ git push 82 | ``` 83 | 84 | On error, your commit will be rejected. 85 | 86 | 87 | ### Examples 88 | 89 | Take a look at the [lib/example/](lib/example/) and [tests](test/unit/) 90 | folders. 91 | 92 | 93 | ## Development 94 | 95 | To run tests, simply invoke `rake`. 96 | 97 | ## Debug output 98 | 99 | To get a detailed log of what happens on a `git push`, modify 100 | `bin/hooks/pre-receive` on the server: 101 | 102 | ```diff 103 | # Generate Zones 104 | -ruby -I$basedir/lib $basedir/bin/run.rb 105 | +DNSGIT_DEBUG=all ruby -I$basedir/lib $basedir/bin/run.rb 106 | ``` 107 | 108 | You can reduce the log amount by setting `DNSGIT_DEBUG` to a comma-separated 109 | list of (lowercase) class names. Known log-enabled classes include: 110 | 111 | - `bind` - for the BIND backend 112 | - `sqlite` - for the SQLite backend 113 | - `work` - for details in the SQLite backend 114 | - `zone` - logs effects of your DSL files 115 | 116 | The class names of log-enabled classes are printed in magenta in the 117 | log output. 118 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | 3 | Rake::TestTask.new(:test) do |t| 4 | t.libs << "test" 5 | t.libs << "lib" 6 | t.test_files = FileList["test/**/*_test.rb"] 7 | end 8 | 9 | task default: :test 10 | -------------------------------------------------------------------------------- /bin/hooks/pre-receive: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | basedir="`dirname $0`/../.." 4 | 5 | # Extract the new commit id 6 | ref=`cat /dev/stdin | awk '{ print $2 }'` 7 | 8 | # Export working copy 9 | export GIT_WORK_TREE=$basedir/tmp/cache 10 | mkdir -p $GIT_WORK_TREE 11 | env -u GIT_QUARANTINE_PATH GIT_WORK_TREE=$basedir/tmp/cache git checkout -f --quiet $ref 12 | 13 | # This loads RVM into a shell session. 14 | for file in $HOME/.rvm/scripts/rvm /etc/profile.d/rvm.sh; do 15 | if [[ -s "$file" ]]; then 16 | source $file 17 | break 18 | fi 19 | done 20 | 21 | #ruby --version 22 | 23 | # Generate Zones 24 | ruby -I$basedir/lib $basedir/bin/run.rb 25 | -------------------------------------------------------------------------------- /bin/init: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | basedir=$( readlink -f $( dirname $0 )/.. ) 4 | repo=$basedir/data 5 | tmp=$basedir/tmp/init 6 | 7 | if [ -e $repo ]; then 8 | echo $repo does already exist 9 | exit 1 10 | fi 11 | 12 | # Create a temporary directoy and fill working copy with examples 13 | mkdir -p $tmp 14 | cd $tmp 15 | cp -R $basedir/lib/example/* ./ 16 | cp $basedir/lib/example/.gitignore ./ 17 | 18 | # Initialize repository 19 | git init --quiet 20 | git add -A 21 | git config user.name || git config user.name "dnsgit" 22 | git config user.email || git config user.email "nobody@example.com" 23 | git commit -m "Sample configuration" --quiet 24 | 25 | # Symlink hooks 26 | rm -rf .git/hooks 27 | ln -s ../bin/hooks .git/ 28 | 29 | # Move bare repository and remove working copy 30 | mv $tmp/.git $repo 31 | rm -rf $tmp 32 | 33 | echo "Please clone and update the configuration:" 34 | echo " git clone $(id -un)@$(hostname -f):$repo dns-config" 35 | -------------------------------------------------------------------------------- /bin/run.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../lib/environment" 4 | 5 | ZoneGenerator.run "#{__dir__}/.." 6 | -------------------------------------------------------------------------------- /lib/backend.rb: -------------------------------------------------------------------------------- 1 | module Backend 2 | autoload :Base, "backend/base" 3 | autoload :BIND, "backend/bind" 4 | autoload :SQLite, "backend/sqlite" 5 | end 6 | -------------------------------------------------------------------------------- /lib/backend/base.rb: -------------------------------------------------------------------------------- 1 | module Backend 2 | class Base 3 | include DebugLog::Logger 4 | 5 | attr_reader :base_dir, :src_template_dir, :src_zones_files 6 | attr_reader :config, :soa, :zones_changed 7 | 8 | def initialize(base_dir, config) 9 | @base_dir = base_dir 10 | src = base_dir.join("tmp/cache") 11 | @src_template_dir = src.join("templates") 12 | @src_zones_files = Pathname.glob(src.join("zones/**/*.rb")).sort 13 | 14 | @config = config 15 | @soa = { 16 | origin: "@", 17 | ttl: "86400", 18 | primary: "example.com.", 19 | email: "hostmaster@example.com", 20 | refresh: "8H", 21 | retry: "2H", 22 | expire: "1W", 23 | minimumTTL: "11h" 24 | }.merge config.fetch(:soa) 25 | 26 | # Rewrite email address 27 | @soa[:email].sub!("@", ".") if @soa[:email].include?("@") 28 | @soa[:email] << "." if @soa[:email][-1] != "." 29 | 30 | @zones_changed = [] 31 | end 32 | 33 | def mark_changed(domain, reason) 34 | puts "#{domain} has been #{reason}" 35 | @zones_changed << domain 36 | end 37 | 38 | def deploy 39 | raise NotImplementedError, "must be implemented in subclass" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/backend/bind.rb: -------------------------------------------------------------------------------- 1 | module Backend 2 | class BIND < Base 3 | attr_reader :dst_named_conf, :dst_zones_dir 4 | 5 | def initialize(*) 6 | super 7 | @tmp = base_dir.join "tmp/generated" 8 | @tmp_named_conf = @tmp.join "named.conf" 9 | @tmp_zones_dir = @tmp.join "zones" 10 | 11 | @dst_named_conf = Pathname.new config.fetch(:bind).fetch(:named_conf) 12 | @dst_zones_dir = Pathname.new config.fetch(:bind).fetch(:zones_dir) 13 | end 14 | 15 | def deploy 16 | generate! 17 | 18 | logger.debug { "remove zones directory" } 19 | dst_zones_dir.rmtree 20 | 21 | logger.debug { "copy generated files" } 22 | FileUtils.copy @tmp_named_conf, dst_named_conf 23 | FileUtils.copy_entry @tmp_zones_dir, dst_zones_dir 24 | end 25 | 26 | private 27 | 28 | # Generate all zones 29 | def generate! 30 | # we don't want dead zone definitions 31 | @tmp.rmtree if @tmp.exist? 32 | @tmp.mkpath 33 | 34 | @tmp_named_conf.open("w") do |f| 35 | src_zones_files.each do |file| 36 | domain = file.basename.sub_ext("").to_s 37 | logger.debug { "generating zone #{domain}" } 38 | generate_zone(file, domain) 39 | 40 | f.puts %Q 41 | end 42 | end 43 | end 44 | 45 | # Generate single zone 46 | def generate_zone(file, domain) 47 | zone = Zone.new(domain, src_template_dir.to_s, soa) 48 | zone.send :eval_file, file.to_s 49 | new_zonefile = zone.zonefile 50 | 51 | # path to the deployed version 52 | old_file = dst_zones_dir.join(domain) 53 | 54 | if old_file.exist? 55 | logger.debug { "found an already deployed version" } 56 | # parse the deployed version 57 | old_output = old_file.read 58 | old_zonefile = Zonefile.new(old_output) 59 | new_zonefile.soa[:serial] = old_zonefile.soa[:serial] 60 | 61 | # content of the new version 62 | new_output = new_zonefile.output 63 | 64 | # has anything changed? 65 | if new_output != old_output 66 | logger.debug { "generating new serial" } 67 | mark_changed(domain, :updated) 68 | # increment serial 69 | new_zonefile.new_serial 70 | new_output = new_zonefile.output 71 | else 72 | logger.debug { "nothing has changed" } 73 | end 74 | else 75 | logger.debug { "zone has not existed before" } 76 | mark_changed(domain, :created) 77 | new_zonefile.new_serial 78 | new_output = new_zonefile.output 79 | end 80 | 81 | logger.debug { "writing new zonefile" } 82 | output_file_path = @tmp_zones_dir.join(domain) 83 | output_file_path.dirname.mkpath 84 | output_file_path.open("w") {|f| f.write new_output } 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/backend/schema.sql: -------------------------------------------------------------------------------- 1 | -- https://github.com/PowerDNS/pdns/blob/rel/auth-4.1.x/modules/gsqlite3backend/schema.sqlite3.sql 2 | -- should work with 4.2 as well (4.2 removes autoserials (records.change_date column)) 3 | 4 | PRAGMA foreign_keys = 1; 5 | 6 | CREATE TABLE domains ( 7 | id INTEGER PRIMARY KEY, 8 | name VARCHAR(255) NOT NULL COLLATE NOCASE, 9 | master VARCHAR(128) DEFAULT NULL, 10 | last_check INTEGER DEFAULT NULL, 11 | type VARCHAR(6) NOT NULL, 12 | notified_serial INTEGER DEFAULT NULL, 13 | account VARCHAR(40) DEFAULT NULL 14 | ); 15 | 16 | CREATE UNIQUE INDEX name_index ON domains(name); 17 | 18 | 19 | CREATE TABLE records ( 20 | id INTEGER PRIMARY KEY, 21 | domain_id INTEGER DEFAULT NULL, 22 | name VARCHAR(255) DEFAULT NULL, 23 | type VARCHAR(10) DEFAULT NULL, 24 | content VARCHAR(65535) DEFAULT NULL, 25 | ttl INTEGER DEFAULT NULL, 26 | prio INTEGER DEFAULT NULL, 27 | change_date INTEGER DEFAULT NULL, 28 | disabled BOOLEAN DEFAULT 0, 29 | ordername VARCHAR(255), 30 | auth BOOL DEFAULT 1, 31 | FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE 32 | ); 33 | 34 | CREATE INDEX rec_name_index ON records(name); 35 | CREATE INDEX nametype_index ON records(name,type); 36 | CREATE INDEX domain_id ON records(domain_id); 37 | CREATE INDEX orderindex ON records(ordername); 38 | 39 | 40 | CREATE TABLE supermasters ( 41 | ip VARCHAR(64) NOT NULL, 42 | nameserver VARCHAR(255) NOT NULL COLLATE NOCASE, 43 | account VARCHAR(40) NOT NULL 44 | ); 45 | 46 | CREATE UNIQUE INDEX ip_nameserver_pk ON supermasters(ip, nameserver); 47 | 48 | 49 | CREATE TABLE comments ( 50 | id INTEGER PRIMARY KEY, 51 | domain_id INTEGER NOT NULL, 52 | name VARCHAR(255) NOT NULL, 53 | type VARCHAR(10) NOT NULL, 54 | modified_at INT NOT NULL, 55 | account VARCHAR(40) DEFAULT NULL, 56 | comment VARCHAR(65535) NOT NULL, 57 | FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE 58 | ); 59 | 60 | CREATE INDEX comments_domain_id_index ON comments (domain_id); 61 | CREATE INDEX comments_nametype_index ON comments (name, type); 62 | CREATE INDEX comments_order_idx ON comments (domain_id, modified_at); 63 | 64 | 65 | CREATE TABLE domainmetadata ( 66 | id INTEGER PRIMARY KEY, 67 | domain_id INT NOT NULL, 68 | kind VARCHAR(32) COLLATE NOCASE, 69 | content TEXT, 70 | FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE 71 | ); 72 | 73 | CREATE INDEX domainmetaidindex ON domainmetadata(domain_id); 74 | 75 | 76 | CREATE TABLE cryptokeys ( 77 | id INTEGER PRIMARY KEY, 78 | domain_id INT NOT NULL, 79 | flags INT NOT NULL, 80 | active BOOL, 81 | content TEXT, 82 | FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE 83 | ); 84 | 85 | CREATE INDEX domainidindex ON cryptokeys(domain_id); 86 | 87 | 88 | CREATE TABLE tsigkeys ( 89 | id INTEGER PRIMARY KEY, 90 | name VARCHAR(255) COLLATE NOCASE, 91 | algorithm VARCHAR(50) COLLATE NOCASE, 92 | secret VARCHAR(255) 93 | ); 94 | 95 | CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); 96 | -------------------------------------------------------------------------------- /lib/backend/sqlite.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | Bundler.require :sqlite 3 | 4 | module Backend 5 | class SQLite < Base 6 | attr_reader :db, :meta 7 | 8 | def initialize(*) 9 | super 10 | 11 | cfg = config.fetch(:sqlite) 12 | @db = SQLite3::Database.new cfg.fetch(:db_path) 13 | @meta = cfg.fetch(:meta, {}) 14 | 15 | if (n = cfg.fetch(:retries, 10).to_i) && n > 0 16 | @db.busy_handler { |count| 17 | puts "Database is busy, retry #{count+1} of #{n} in 1s" 18 | sleep 1 19 | count < n 20 | } 21 | end 22 | 23 | db.foreign_keys = true 24 | prime_database! 25 | prepare_statements! 26 | end 27 | 28 | def close 29 | @prepared_statements.each do |_, stmt| 30 | stmt.close 31 | end 32 | @db.close 33 | end 34 | 35 | Work = Struct.new(:zonefile, :id, :need_update) do 36 | include ::DebugLog::Logger 37 | 38 | def checksum 39 | @checksum ||= begin 40 | digest = Digest::SHA1.new 41 | each_rr do |rr| 42 | logger.debug { format " %{name}\t%{ttl}\t%{class}\t%{type}\t%{data}", rr.to_h } 43 | digest << rr.name.to_s << rr.ttl.to_s << rr.type.to_s << rr.data 44 | end 45 | 46 | digest.hexdigest.tap {|hd| logger.debug hd } 47 | end 48 | end 49 | 50 | def each_rr 51 | zonefile.resource_records(powerdns_sql: true).each do |typ, rrs| 52 | rrs = [rrs] if typ == :soa 53 | rrs.each {|rr| yield rr } 54 | end 55 | end 56 | 57 | def bump_serial! 58 | zonefile.new_serial 59 | @checksum = nil 60 | end 61 | 62 | def serial=(value) 63 | zonefile.soa[:serial] = value 64 | @checksum = nil 65 | end 66 | end 67 | private_constant :Work 68 | 69 | def deploy 70 | zones = {} 71 | 72 | src_zones_files.each do |file| 73 | domain = file.basename.sub_ext("").to_s 74 | logger.debug { "build zonefile for #{domain}" } 75 | zonefile = build_zone_file(file, domain) 76 | 77 | zones[domain] = Work.new(zonefile).tap {|work| 78 | # will be annotated with current values in #annotate_state 79 | work.serial = zonefile.soa[:serial] || 0 80 | work.need_update = true 81 | } 82 | end 83 | 84 | logger.debug { :annotate_state } 85 | annotate_state(zones) 86 | 87 | logger.debug { :update_database } 88 | update_database(zones) 89 | end 90 | 91 | private 92 | 93 | INSERT_DOMAIN_SQL = <<~SQL.freeze 94 | insert into domains (name, type) 95 | values (:name, 'MASTER') 96 | SQL 97 | private_constant :INSERT_DOMAIN_SQL 98 | 99 | UPDATE_DOMAIN_CHECKSUM_SQL = <<~SQL.freeze 100 | update domains 101 | set dnsgit_zone_hash = :checksum 102 | where id = :id 103 | SQL 104 | private_constant :UPDATE_DOMAIN_CHECKSUM_SQL 105 | 106 | DELETE_RECORD_SQL = <<~SQL.freeze 107 | delete from records where domain_id = :domain_id 108 | SQL 109 | private_constant :DELETE_RECORD_SQL 110 | 111 | INSERT_RECORD_SQL = <<~SQL.freeze 112 | insert into records (domain_id, name, type, content, ttl, prio, disabled) 113 | values (:domain_id, :name, :type, :content, :ttl, :prio, 0) 114 | SQL 115 | private_constant :INSERT_RECORD_SQL 116 | 117 | UPDATE_DOMAIN_METADATA_SQL = <<~SQL.freeze 118 | insert or replace into domainmetadata (id, domain_id, kind, content) 119 | values ( 120 | (select id from domainmetadata where domain_id = :domain_id and kind = :kind), 121 | :domain_id, 122 | :kind, 123 | :content 124 | ) 125 | SQL 126 | private_constant :UPDATE_DOMAIN_METADATA_SQL 127 | 128 | # retrieves the SOA record's serial number for each domain in 129 | # `zones` and overrides the value of a domain's zonefile. 130 | def annotate_state(zones) 131 | q = <<~SQL.freeze 132 | select domains.id 133 | , domains.name 134 | , domains.dnsgit_zone_hash 135 | , records.content 136 | from domains 137 | inner join records on records.domain_id = domains.id 138 | where records.type = 'SOA' 139 | SQL 140 | 141 | execute(q) do |(id, domain, checksum, rrdata)| 142 | next unless zones.key?(domain) 143 | _, _, serial, _, _, _, _ = rrdata.split(/\s+/) 144 | 145 | zones[domain].id = id 146 | zones[domain].serial = serial 147 | zones[domain].need_update = zones[domain].checksum != checksum 148 | 149 | logger.info { "domain #{domain} needs update" } if zones[domain].need_update 150 | end 151 | end 152 | 153 | def update_database(zones) 154 | logger.debug { "insert new domains" } 155 | db.transaction do 156 | zones.each do |domain, work| 157 | next if work.id 158 | execute_prepared(:insert_domain, "name" => domain) 159 | work.id = db.last_insert_row_id 160 | end 161 | end 162 | 163 | logger.debug { "find old domains to delete" } 164 | extra = { domains: [], ids: [] } 165 | execute "select name, id from domains" do |(domain, id)| 166 | next if zones.key?(domain) 167 | extra[:domains] << domain 168 | extra[:ids] << id 169 | end 170 | 171 | if extra[:ids].length == 0 172 | logger.debug { "no old domains to delete" } 173 | else 174 | logger.info { "delete old domains: #{extra[:domains].join(', ')}" } 175 | execute "delete from domains where id in (#{extra[:ids].join(', ')})" 176 | end 177 | 178 | zones.each do |domain, work| 179 | set_metadata(work.id) 180 | next unless work.need_update 181 | 182 | if work.id.nil? 183 | logger.error { "missing ID for #{domain}" } 184 | next 185 | end 186 | 187 | db.transaction do 188 | logger.info { "rebuild records for #{domain}" } 189 | execute_prepared(:delete_record, "domain_id" => work.id) 190 | 191 | work.bump_serial! 192 | work.each_rr do |rr| 193 | upsert_domain_record(work.id, rr, work.zonefile.ttl) 194 | end 195 | 196 | execute_prepared(:domain_checksum, { 197 | "id" => work.id, 198 | "checksum" => work.checksum, 199 | }) 200 | mark_changed(domain, :updated) 201 | end 202 | end 203 | end 204 | 205 | def upsert_domain_record(domain_id, rr, default_ttl) 206 | prio, content = if %w[MX SRV].include?(rr.type) 207 | rr.data.split(/\s+/, 2) 208 | else 209 | [0, rr.data] 210 | end 211 | 212 | execute_prepared(:insert_record, { 213 | "domain_id" => domain_id, 214 | "name" => rr.name, 215 | "type" => rr.type, 216 | "content" => content, 217 | "ttl" => rr.ttl || default_ttl, 218 | "prio" => prio, 219 | }) 220 | end 221 | 222 | def prepare_statements! 223 | @prepared_statements = { 224 | insert_domain: db.prepare(INSERT_DOMAIN_SQL), 225 | domain_checksum: db.prepare(UPDATE_DOMAIN_CHECKSUM_SQL), 226 | delete_record: db.prepare(DELETE_RECORD_SQL), 227 | insert_record: db.prepare(INSERT_RECORD_SQL), 228 | update_metadata: db.prepare(UPDATE_DOMAIN_METADATA_SQL), 229 | } 230 | end 231 | 232 | def execute_prepared(name, *args) 233 | logger.debug { debug_sql(prep_stmt: name, args: args) } 234 | @prepared_statements.fetch(name).execute(*args) 235 | end 236 | 237 | def execute(query, *args, &block) 238 | logger.debug { debug_sql(query: query.gsub(/\s*\n\s*/, " ").strip, args: args) } 239 | db.execute(query, *args, &block) 240 | end 241 | 242 | def build_zone_file(file, domain) 243 | zone = Zone.new(domain, src_template_dir.to_s, soa) 244 | zone.send :eval_file, file.to_s 245 | zone.zonefile 246 | end 247 | 248 | def set_metadata(domain_id) 249 | return if domain_id.nil? || meta.empty? 250 | 251 | db.transaction do 252 | meta.each do |kind, content| 253 | # :notify_dnsupdate => "NOTIFY-DNSUPDATE" 254 | kind = kind.to_s.split("_").join("-").upcase 255 | value = value.to_s 256 | 257 | execute_prepared(:update_metadata, { 258 | "domain_id" => domain_id, 259 | "kind" => kind, 260 | "content" => content, 261 | }) 262 | end 263 | end 264 | end 265 | 266 | def prime_database! 267 | check_pdns_schema! # create upstream schema 268 | check_helper_columns! # modify schema/custom migrations 269 | end 270 | 271 | def check_pdns_schema! 272 | have = Set.new 273 | want = Set.new %w[ domains records supermasters comments domainmetadata cryptokeys tsigkeys ] 274 | 275 | db.execute("select name from sqlite_master where type='table'") do |(name)| 276 | have << name 277 | end 278 | 279 | diff = want - have 280 | 281 | # do we have it all? 282 | return if diff.size == 0 283 | 284 | # do we have some? 285 | if 0 < diff.size && diff.size < want.size 286 | raise "unknown DB state, we have columns #{have.to_a} and want #{want.to_a}" 287 | end 288 | 289 | # should not occur 290 | if diff.size != want.size 291 | raise "bug: #{have.to_a} is not empty!?" 292 | end 293 | 294 | # db is empty, apply schema 295 | schema = Pathname.new(__dir__).join("schema.sql").read 296 | db.execute_batch(schema) 297 | end 298 | 299 | def check_helper_columns! 300 | # we use SHA1(zonefile) to determine changes in a zone 301 | have_zone_hash = db.table_info(:domains) 302 | .map {|info| info["name"] } 303 | .include?("dnsgit_zone_hash") 304 | 305 | unless have_zone_hash 306 | db.execute "alter table domains add column dnsgit_zone_hash varchar(40) default null" 307 | end 308 | end 309 | 310 | def debug_sql(args: nil, **rest) 311 | if args && args.size > 0 312 | rest = rest.merge(args: args) 313 | end 314 | format " %p", rest 315 | end 316 | end 317 | end 318 | -------------------------------------------------------------------------------- /lib/debug_log.rb: -------------------------------------------------------------------------------- 1 | class DebugLog 2 | module Logger 3 | private 4 | def logger 5 | @logger ||= ::DebugLog.new(self.class.name.split("::").last) 6 | end 7 | end 8 | 9 | ENABLED_FOR = Set.new(ENV.fetch("DNSGIT_DEBUG", "").downcase.split(",")).freeze 10 | 11 | COLOR_CODES = { 12 | DEBUG: 34, # blue 13 | INFO: [36,1], # bright cyan 14 | WARN: [33,1], # bright yellow 15 | ERROR: 30, # red 16 | }.freeze 17 | 18 | attr_reader :prefix, :enabled 19 | 20 | def initialize(prefix, force_enable=false) 21 | @prefix = prefix 22 | @enabled = force_enable || 23 | ENABLED_FOR.include?("all") || 24 | ENABLED_FOR.include?(prefix.downcase) 25 | end 26 | 27 | def debug(msg=nil, &block) 28 | log :DEBUG, msg, &block 29 | end 30 | 31 | def info(msg=nil, &block) 32 | log :INFO, msg, &block 33 | end 34 | 35 | def warn(msg=nil, &block) 36 | log :WARN, msg, &block 37 | end 38 | 39 | def error(msg=nil, &block) 40 | log :ERROR, msg, &block 41 | end 42 | 43 | private 44 | 45 | def log(level, msg=nil, &block) 46 | return unless enabled 47 | 48 | msg ||= yield if block_given? 49 | c = [*COLOR_CODES.fetch(level, 0)].join(";") 50 | $stderr.printf "\e[%sm%-5s\e[0m \e[%dm%s\e[0m\t%s\n", c, level, 35, prefix, msg.to_s 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/environment.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler" 3 | 4 | Bundler.require(:default) 5 | 6 | require_relative "hash_ext" 7 | require_relative "debug_log" 8 | require_relative "zone" 9 | require_relative "backend" 10 | require_relative "zone_generator" 11 | -------------------------------------------------------------------------------- /lib/example/.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .* 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /lib/example/config.yaml: -------------------------------------------------------------------------------- 1 | # PowerDNS configuration 2 | # 3 | # Either one of "bind" or "sqlite" must be present, but not both. 4 | bind: 5 | named_conf: /etc/powerdns/named.conf 6 | zones_dir: /var/lib/powerdns/zones 7 | sqlite: 8 | db_path: /var/lib/powerdns/powerdns.sqlite3 9 | 10 | # Command or array of commands to run after push. The working directory 11 | # is a remote checkout of the repository. A list of changed zones 12 | # as comma-separated of domain names is available as $ZONES_CHANGED 13 | # environment variable. 14 | execute: '/usr/bin/pdns_control reload' 15 | 16 | # SOA Record 17 | # Recommendations: http://www.ripe.net/ripe/docs/ripe-203 18 | soa: 19 | primary: "ns1.example.com." 20 | email: "webmaster@example.com" 21 | ttl: "1H" 22 | refresh: "1D" 23 | retry: "3H" 24 | expire: "1W" 25 | minimumTTL: "2D" 26 | -------------------------------------------------------------------------------- /lib/example/templates/example-dns.rb: -------------------------------------------------------------------------------- 1 | 2 | # NS records 3 | ns "ns1.example.com." 4 | ns "ns2.example.de." 5 | ns "ns3.example.de." 6 | -------------------------------------------------------------------------------- /lib/example/zones/example.com.rb: -------------------------------------------------------------------------------- 1 | 2 | soa minimumTTL: 3600*12 3 | 4 | template "example-dns" 5 | 6 | # A records 7 | a "a.ns", "192.168.1.2", 3600 8 | a "b.ns", "192.168.1.3", 3600 9 | a "mx1", "192.168.1.11" 10 | a "mx2", "192.168.1.12" 11 | a "sipserver", "192.168.1.200" 12 | 13 | # AAAA records 14 | aaaa "2001:4860:4860::8888" 15 | 16 | # MX records 17 | mx "mx1", 10 18 | mx "mx2", 20 19 | 20 | # CNAME records 21 | cname "www", "@" 22 | txt "google-site-verification=vEj1ZcGtXeM_UEjnCqQEhxPSqkS9IQ4PBFuh48FP8o4" 23 | 24 | # SRV records 25 | srv :sip, :tcp, "sipserver.example.net.", 5060 26 | 27 | # TLSA record 28 | tlsa "@", 443, :tcp, 0, 0, 1, "e36d9e402c6308273375b68297f7ae207521238f0cd812622672f0f2ce67eb1c" 29 | 30 | # Wildcard records 31 | a "*.user", "192.168.1.100" 32 | mx "*.user", "mail" 33 | 34 | # Alternate nesting syntax (only for txt and cname records). 35 | # The following is equivalent to this: 36 | # 37 | # a "i.am.nested", "10.20.30.40" 38 | # cname "me.as.well", "i.am.nested" 39 | # cname "another-name", "i.am.nested", 600 40 | # txt "i.am.nestet", "site-verification=token" 41 | a "i.am.nested", "10.20.30.40" do 42 | cname "me.as.well" 43 | cname "anoter-name", 600 44 | txt "site-verification=token" 45 | end 46 | -------------------------------------------------------------------------------- /lib/hash_ext.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | def symbolize_keys! 3 | keys.each do |key| 4 | if key.respond_to? :to_sym 5 | self[key.to_sym || key] = delete(key) 6 | end 7 | end 8 | self 9 | end 10 | 11 | def deep_symbolize_keys! 12 | symbolize_keys! 13 | 14 | values.each do |v| 15 | case v 16 | when Hash # symbolize each hash in .values 17 | v.deep_symbolize_keys! if v.is_a?(Hash) 18 | when Array # symbolize each hash inside an array in .values 19 | v.each {|h| h.deep_symbolize_keys! if h.is_a?(Hash) } 20 | end 21 | end 22 | 23 | self 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/zone.rb: -------------------------------------------------------------------------------- 1 | class Zone 2 | include ::DebugLog::Logger 3 | 4 | class Nested < Struct.new(:zone, :host) 5 | def cname(name, ttl=nil) 6 | zone.cname(*[name, host, ttl].compact) 7 | end 8 | 9 | def txt(text, ttl=nil) 10 | zone.txt(*[host, text, ttl].compact) 11 | end 12 | end 13 | 14 | SOA_FIELDS = %i( 15 | ttl 16 | origin 17 | ttl 18 | primary 19 | email 20 | serial 21 | refresh 22 | retry 23 | expire 24 | minimumTTL 25 | ) 26 | 27 | attr_reader :zonefile 28 | 29 | def initialize(domain, template_dir, soa={}) 30 | @domain = domain 31 | @zonefile = Zonefile.new("", "output/#{domain}", domain) 32 | logger.debug { format " soa: %p", soa } 33 | @zonefile.soa.merge! soa 34 | @template_dir = template_dir 35 | end 36 | 37 | def template(name) 38 | eval_file "#{@template_dir}/#{name}.rb" 39 | end 40 | 41 | # Merge 42 | def soa(**options) 43 | if (invalid_keys = options.keys - SOA_FIELDS).any? 44 | raise ArgumentError, "invalid options: #{invalid_keys.inspect}" 45 | end 46 | logger.debug { format " %p", options } 47 | @zonefile.soa.merge! options 48 | end 49 | 50 | # 1.2.3.4 - host 51 | # 1.2.3.4, 600 - host with TTL 52 | # www, 1.2.3.4, 600 - name, host and TTL 53 | def a(*args, &block) 54 | if [String, String, String] == args[0..2].map(&:class) 55 | # name, ipv4 and ipv6 56 | name, ipv4, ipv6 = args.shift(3) 57 | a_record :a, name, ipv4, *args, &block 58 | a_record :a4, name, ipv6, *args 59 | else 60 | a_record :a, *args, &block 61 | end 62 | end 63 | 64 | def aaaa(*args, &block) 65 | a_record :a4, *args, &block 66 | end 67 | 68 | def a_record(type, *args, &block) 69 | ttl = extract_ttl! args 70 | host = args.pop 71 | name = args.pop || '@' 72 | 73 | push(type, name, ttl, host: host) if present?(host) 74 | Nested.new(self, name).instance_eval(&block) if block_given? 75 | end 76 | 77 | # mx - host with default priority (10) 78 | # mx, 15 - host and priority 79 | # mx, 15, 600 - host, priority and TTL 80 | # name, mx, 15 - name, host, priority 81 | # name, mx, 15, 600 - name, host, priority and TTL 82 | def mx(*args) 83 | if args[1].is_a?(String) 84 | # name and host given 85 | name, host = args.shift(2) 86 | else 87 | # only host given 88 | host = args.shift || '@' 89 | name = '@' 90 | end 91 | 92 | pri = args.shift || 10 93 | ttl = args.shift 94 | 95 | push :mx, name, ttl, host: host, pri: pri 96 | end 97 | 98 | # ns1.example.com. - host 99 | # ns1.example.com., 600 - host with TTL 100 | def ns(*args) 101 | ttl = extract_ttl! args 102 | host = args.pop 103 | name = args.pop || '@' 104 | 105 | push(:ns, name, ttl, host: host) if present?(host) 106 | end 107 | 108 | def cname(name, *args) 109 | ttl = extract_ttl! args 110 | host = args.pop || "@" 111 | 112 | push :cname, name, ttl, host: host 113 | end 114 | 115 | def srv(*args) 116 | options = extract_options! args 117 | name = "." << args.shift if args[0].is_a?(String) 118 | 119 | raise ArgumentError, "wrong number of arguments" unless (4..5).include?(args.count) 120 | 121 | service, proto, host, port = args.shift(4) 122 | ttl = extract_ttl! args 123 | 124 | options.each do |key, val| 125 | case key 126 | when :pri, :weight 127 | raise ArgumentError, "invalid #{key}: #{val}" if val.to_s !~ /^\d+$/ 128 | else 129 | raise ArgumentError, "unknown option: #{key}" 130 | end 131 | end 132 | 133 | # default values 134 | options[:pri] ||= 10 135 | options[:weight] ||= 0 136 | 137 | push :srv, "_#{service}._#{proto}#{name}", ttl, options.merge(host: host, port: port) 138 | end 139 | 140 | def txt(*args) 141 | ttl = extract_ttl! args 142 | text = args.pop.to_s.strip 143 | name = args.pop || '@' 144 | text = "\"#{text}\"" if text =~ /\s/ 145 | 146 | push :txt, name, ttl, text: text 147 | end 148 | 149 | def tlsa(*args) 150 | ttl = extract_ttl! args 151 | name = args.shift if String===args[0] 152 | name = (name=="@" || !name) ? '' : "." << name 153 | port, proto, usage, selector, matching, data = args.shift(6) 154 | 155 | raise ArgumentError, "invalid port: #{port}" if port < 0 || port > 65535 156 | raise ArgumentError, "invalid protocol: #{proto}" if proto.to_s !~ /^[a-z]+$/ 157 | raise ArgumentError, "no data given" unless data 158 | raise ArgumentError, "invalid usage: #{usage}" unless Integer === usage 159 | raise ArgumentError, "invalid selector: #{selector}" unless Integer === selector 160 | raise ArgumentError, "invalid matching_type: #{matching}" unless Integer === matching 161 | 162 | push :tlsa, "_#{port}._#{proto}#{name}", ttl, 163 | certificate_usage: usage, 164 | selector: selector, 165 | matching_type: matching, 166 | data: data 167 | end 168 | 169 | # name in not-reversed order 170 | def ptr(name, host, ttl=nil) 171 | host = "#{host}." if host[-1] != '.' 172 | push :ptr, name.to_s, ttl, host: host 173 | end 174 | 175 | def ptr6(name, *args) 176 | raise ArgumentError, "no double colon allowed" if name.include?("::") 177 | 178 | # left fill blocks with zeros, reverse order all characters and join them with points 179 | ptr name.split(":").map{|b| b.rjust(4,"0") }.join.reverse.split("").join("."), *args 180 | end 181 | 182 | protected 183 | 184 | # evaluates a file 185 | def eval_file(file) 186 | instance_eval File.read(file), file 187 | end 188 | 189 | def push(type, name, ttl, options={}) 190 | rrdata = { 191 | class: "IN", 192 | name: name, 193 | ttl: ttl, 194 | }.merge(options) 195 | 196 | logger.debug { format " %s %p", type, rrdata } 197 | @zonefile.send(type) << rrdata 198 | end 199 | 200 | # extracts the last argument if it is a Hash 201 | def extract_options!(args) 202 | args.last.is_a?(Hash) ? args.pop : {} 203 | end 204 | 205 | # extracts the last argument if it is an Integer 206 | def extract_ttl!(args) 207 | args.pop if args.last.is_a?(Integer) 208 | end 209 | 210 | def present?(s) 211 | !s.nil? && s != "" 212 | end 213 | end 214 | 215 | -------------------------------------------------------------------------------- /lib/zone_generator.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | require "yaml" 3 | 4 | class ZoneGenerator 5 | def self.run(basedir) 6 | new(basedir).deploy 7 | end 8 | 9 | attr_reader :workspace 10 | 11 | def initialize(basedir) 12 | basedir = Pathname.new(basedir) 13 | @workspace = basedir.join("tmp/cache") 14 | config = YAML.load workspace.join("config.yaml").read 15 | config.deep_symbolize_keys! 16 | check_config!(config) 17 | 18 | @backend = if config.key?(:bind) 19 | Backend::BIND.new(basedir, config) 20 | elsif config.key?(:sqlite) 21 | Backend::SQLite.new(basedir, config) 22 | end 23 | 24 | @after_deploy = [*config[:execute]] 25 | end 26 | 27 | # Performs deployment and executes callbacks 28 | def deploy 29 | @backend.deploy 30 | @backend.close if @backend.respond_to?(:close) 31 | 32 | env = { 33 | "ZONES_CHANGED" => @backend.zones_changed.join(","), 34 | } 35 | @after_deploy.each do |cmd| 36 | Dir.chdir(workspace) do 37 | puts_in_yellow "Executing '#{cmd}' ..." 38 | puts IO.popen(env, cmd, "r", err: [:child, :out], &:read) 39 | if $?.exitstatus != 0 40 | puts_in_red "command finished with status #{$?.exitstatus}" 41 | exit $?.exitstatus 42 | end 43 | end 44 | end 45 | end 46 | 47 | private 48 | 49 | def check_config!(config) 50 | errors = [] 51 | 52 | if !(config.key?(:bind) ^ config.key?(:sqlite)) 53 | errors << "please ensure you have exactly one setting for 'bind' or 'sqlite'" 54 | 55 | elsif config.key?(:bind) 56 | if !config[:bind].key?(:named_conf) 57 | errors << "mssing 'bind.named_conf'" 58 | end 59 | 60 | if !config[:bind].key?(:zones_dir) 61 | errors << "missing 'bind.zones_dir'" 62 | end 63 | 64 | elsif config.key?(:sqlite) 65 | if !config[:sqlite].key?(:db_path) 66 | errors << "missing 'sqlite.db_path'" 67 | end 68 | end 69 | 70 | if !config.key?(:soa) 71 | errors << "missing 'soa' settings" 72 | end 73 | 74 | if errors.length > 0 75 | puts_in_red "incomplete or invalid configuration" 76 | errors.each do |err| 77 | puts_in_yellow " - #{err}" 78 | end 79 | exit 1 80 | end 81 | end 82 | 83 | def puts_in_yellow(msg) 84 | printf "\e[33m%s\e[0m\n", msg 85 | end 86 | 87 | def puts_in_red(msg) 88 | printf "\e[31;1m%s\e[0m\n", msg 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/fixtures/on-client/onupdate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | for zone in $(echo $ZONES_CHANGED | sed "s/,/ /g"); do 6 | echo "processing ${zone} ... done" 7 | done 8 | -------------------------------------------------------------------------------- /test/fixtures/on-client/templates/ns.rb: -------------------------------------------------------------------------------- 1 | ns "ns1.example.com." 2 | -------------------------------------------------------------------------------- /test/fixtures/on-client/zones/0.0.127.in-addr.arpa.rb: -------------------------------------------------------------------------------- 1 | # We need a stable serial for tests. in production, you would want 2 | # to leave the serial field untouched. 3 | # 4 | # Note: on Jan. 1st, 2125 the test suite will start to fail. 5 | soa serial: 2124_12_31_00 6 | 7 | template "ns" 8 | 9 | ptr 53, "ns.localhost." 10 | -------------------------------------------------------------------------------- /test/fixtures/on-client/zones/example.com.rb: -------------------------------------------------------------------------------- 1 | # We need a stable serial for tests. in production, you would want 2 | # to leave the serial field untouched. 3 | # 4 | # Note: on Jan. 1st, 2125 the test suite will start to fail. 5 | soa minimumTTL: "12h", 6 | serial: 2124_12_31_00 7 | 8 | template "ns" 9 | 10 | a "@", "192.168.1.1" 11 | a "a", "192.168.1.2", 3600 12 | aaaa "2001:4860:4860::8888" 13 | mx "mx1", 10 14 | cname "www", "@" 15 | -------------------------------------------------------------------------------- /test/fixtures/on-client/zones/example.org.rb: -------------------------------------------------------------------------------- 1 | # We need a stable serial for tests. in production, you would want 2 | # to leave the serial field untouched. 3 | # 4 | # Note: on Jan. 1st, 2125 the test suite will start to fail. 5 | soa minimumTTL: "10m", 6 | serial: 2124_12_31_00 7 | 8 | template "ns" 9 | 10 | a "a", "192.168.1.3", 600 do 11 | cname "foo", 42 # foo 42 IN CNAME a 12 | cname "foo.bar" # foo.bar IN CNAME a 13 | txt "a=b", 120 # @ 120 IN TXT "a=b" 14 | end 15 | 16 | aaaa "2001:4860:4860::6666" 17 | mx "mx1", 10 18 | mx "mx2", 20 19 | 20 | a "b", "10.11.12.13", "2001:4860:4860::abcd" do 21 | cname "c", 60 22 | end 23 | -------------------------------------------------------------------------------- /test/integration/bind_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "test_helper" 3 | 4 | class Backend::TestBIND < IntegrationTest 5 | EMPTY_RRTYPES = %i[ 6 | a a4 dnskey ds naptr nsec nsec3 nsec3param ptr rrsig spf srv tlsa txt caa 7 | ].each_with_object({}) {|rtype, map| 8 | map[rtype] = [] 9 | } 10 | 11 | def setup 12 | init_dnsgit! do 13 | @named_conf = @work.join("pdns/named.conf") 14 | @zones_dir = @work.join("pdns/zones").tap(&:mkpath) 15 | 16 | { 17 | "bind" => { 18 | "named_conf" => @named_conf.to_s, 19 | "zones_dir" => @zones_dir.to_s, 20 | } 21 | } 22 | end 23 | end 24 | 25 | def test_create_files 26 | Dir.chdir @work.join("pdns") do 27 | assert File.exist?("zones/example.com") 28 | assert File.exist?("zones/example.org") 29 | end 30 | end 31 | 32 | def test_execute_hooks 33 | assert_includes @push_output, [ 34 | "0.0.127.in-addr.arpa has been created", 35 | "example.com has been created", 36 | "example.org has been created", 37 | "Executing 'echo 1' ...", 38 | "1", 39 | "Executing './onupdate.sh' ...", 40 | "processing 0.0.127.in-addr.arpa ... done", 41 | "processing example.com ... done", 42 | "processing example.org ... done", 43 | "Executing 'echo 2' ...", 44 | "2", 45 | ].join("\n") 46 | end 47 | 48 | def test_rdns_zone 49 | Dir.chdir @work.join("pdns") do 50 | zf = Zonefile.from_file "zones/0.0.127.in-addr.arpa" 51 | 52 | assert_equal({ 53 | origin: "@", 54 | primary: "ns1.example.com.", 55 | email: "webmaster.example.com.", 56 | refresh: 24 * 3600, 57 | ttl: 3600, 58 | minimumTTL: 48 * 3600, 59 | retry: 3 * 3600, 60 | expire: 7 * 24 * 3600, 61 | serial: "2124123101", 62 | }, zf.soa) 63 | 64 | EMPTY_RRTYPES.merge({ 65 | ptr: [{ name: "53", ttl: nil, class: "IN", host: "ns.localhost." }], 66 | }).each do |rtype, rrs| 67 | assert_equal rrs, zf.records[rtype] 68 | end 69 | end 70 | end 71 | 72 | def test_example_com_zone 73 | Dir.chdir @work.join("pdns") do 74 | zf = Zonefile.from_file "zones/example.com" 75 | 76 | assert_equal({ 77 | origin: "@", 78 | primary: "ns1.example.com.", 79 | email: "webmaster.example.com.", 80 | refresh: 24 * 3600, 81 | ttl: 3600, 82 | minimumTTL: 12 * 3600, 83 | retry: 3 * 3600, 84 | expire: 7 * 24 * 3600, 85 | serial: "2124123101", 86 | }, zf.soa) 87 | 88 | EMPTY_RRTYPES.merge({ 89 | a: [{ name: "@", ttl: nil, class: "IN", host: "192.168.1.1" }, 90 | { name: "a", ttl: 3600, class: "IN", host: "192.168.1.2" }], 91 | a4: [{ name: "@", ttl: nil, class: "IN", host: "2001:4860:4860::8888" }], 92 | cname: [{ name: "www", ttl: nil, class: "IN", host: "@" }], 93 | mx: [{ name: "@", ttl: nil, class: "IN", host: "mx1", pri: 10 }], 94 | ns: [{ name: "@", ttl: nil, class: "IN", host: "ns1.example.com." }], 95 | }).each do |rtype, rrs| 96 | assert_equal rrs, zf.records[rtype] 97 | end 98 | end 99 | end 100 | 101 | def test_example_org_zone 102 | Dir.chdir @work.join("pdns") do 103 | zf = Zonefile.from_file "zones/example.org" 104 | 105 | assert_equal({ 106 | origin: "@", 107 | primary: "ns1.example.com.", 108 | email: "webmaster.example.com.", 109 | refresh: 24 * 3600, 110 | ttl: 3600, 111 | minimumTTL: 600, 112 | retry: 3 * 3600, 113 | expire: 7 * 24 * 3600, 114 | serial: "2124123101", 115 | }, zf.soa) 116 | 117 | EMPTY_RRTYPES.merge({ 118 | a: [{ name: "a", ttl: 600, class: "IN", host: "192.168.1.3" }, 119 | { name: "b", ttl: nil, class: "IN", host: "10.11.12.13" }], 120 | a4: [{ name: "@", ttl: nil, class: "IN", host: "2001:4860:4860::6666" }, 121 | { name: "b", ttl: nil, class: "IN", host: "2001:4860:4860::abcd" }], 122 | cname: [{ name: "foo", ttl: 42, class: "IN", host: "a" }, 123 | { name: "foo.bar", ttl: nil, class: "IN", host: "a" }, 124 | { name: "c", ttl: 60, class: "IN", host: "b" }], 125 | mx: [{ name: "@", ttl: nil, class: "IN", host: "mx1", pri: 10 }, 126 | { name: "@", ttl: nil, class: "IN", host: "mx2", pri: 20 }], 127 | ns: [{ name: "@", ttl: nil, class: "IN", host: "ns1.example.com." }], 128 | txt: [{ name: "a", ttl: 120, class: "IN", text: "a=b" }], 129 | }).each do |rtype, rrs| 130 | assert_equal rrs, zf.records[rtype], "RR type #{rtype} mismatch" 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/integration/sqlite_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "test_helper" 3 | Bundler.require :sqlite 4 | 5 | class Backend::TestSQLite < IntegrationTest 6 | def with_db 7 | SQLite3::Database.new(@db_path.to_s, readonly: true) do |db| 8 | yield db 9 | end 10 | end 11 | 12 | def setup 13 | init_dnsgit! do 14 | @db_path = @work.join("pdns.sqlite3") 15 | { "sqlite" => { "db_path" => @db_path.to_s } } 16 | end 17 | end 18 | 19 | def teardown 20 | keep_db = ENV["DNSGIT_KEEP_DB"] == "1" && @db_path.exist? 21 | 22 | FileUtils.cp @db_path, "/tmp/" if keep_db 23 | super 24 | end 25 | 26 | def test_create_db_file 27 | assert @db_path.exist? 28 | end 29 | 30 | def test_execute_hooks 31 | assert_includes @push_output, [ 32 | "0.0.127.in-addr.arpa has been updated", 33 | "example.com has been updated", 34 | "example.org has been updated", 35 | "Executing 'echo 1' ...", 36 | "1", 37 | "Executing './onupdate.sh' ...", 38 | "processing 0.0.127.in-addr.arpa ... done", 39 | "processing example.com ... done", 40 | "processing example.org ... done", 41 | "Executing 'echo 2' ...", 42 | "2", 43 | ].join("\n") 44 | end 45 | 46 | def test_create_zones 47 | have = {} 48 | with_db do |db| 49 | db.execute("select name, dnsgit_zone_hash from domains") do |(name, checksum)| 50 | have[name] = checksum 51 | end 52 | end 53 | 54 | assert have.key?("example.com") 55 | assert have.key?("example.org") 56 | # If this test fails on or after 2125-01-01: congratulations, you're 57 | # looking at centennial code. Do you still use DNS in the future? 58 | assert_equal "fdaa632ef880afde15d677a6e9cf6ed391b9593e", have["example.com"] 59 | assert_equal "fc9982b93739c4ff3b45becb56b37c220422e344", have["example.org"] 60 | end 61 | 62 | def fetch_records(domain) 63 | records = Hash.new {|h,k| h[k] = [] } 64 | 65 | with_db do |db| 66 | q = <<~SQL.freeze 67 | select records.name 68 | , records.type 69 | , records.content 70 | , records.ttl 71 | , records.prio 72 | from records 73 | inner join domains on domains.id = records.domain_id 74 | where records.disabled = 0 75 | and domains.name = '#{domain}' 76 | SQL 77 | db.execute(q) do |(name, type, content, ttl, prio)| 78 | records[type] << { name: name, content: content, ttl: ttl, prio: prio } 79 | end 80 | end 81 | 82 | records 83 | end 84 | 85 | def test_rdns_zone 86 | have = fetch_records("0.0.127.in-addr.arpa") 87 | 88 | assert_equal [{ 89 | name: "0.0.127.in-addr.arpa", 90 | ttl: 3600, 91 | prio: 0, 92 | content: [ 93 | "ns1.example.com", 94 | "webmaster.example.com", 95 | 2124123101, 96 | 24 * 3600, # refresh 97 | 3 * 3600, # retry 98 | 7 * 24 * 3600, # expire 99 | 48 * 3600, # min ttl 100 | ].join("\t"), 101 | }], have.fetch("SOA"), "RRTYPE SOA mismatch" 102 | 103 | { 104 | "PTR" => [{ name: "53.0.0.127.in-addr.arpa", content: "ns.localhost", ttl: nil, prio: 0 }], 105 | }.each do |rtype, records| 106 | assert_equal records, have[rtype], "RRTYPE #{rtype} mismatch" 107 | end 108 | end 109 | 110 | def test_example_com_zone 111 | have = fetch_records("example.com") 112 | assert_equal [{ 113 | name: "example.com", 114 | ttl: 3600, 115 | prio: 0, 116 | content: [ 117 | "ns1.example.com", 118 | "webmaster.example.com", 119 | 2124123101, 120 | 24 * 3600, # refresh 121 | 3 * 3600, # retry 122 | 7 * 24 * 3600, # expire 123 | 12 * 3600, # min ttl 124 | ].join("\t"), 125 | }], have.fetch("SOA"), "RRTYPE SOA mismatch" 126 | 127 | { 128 | "A" => [{ name: "example.com", content: "192.168.1.1", ttl: nil, prio: 0 }, 129 | { name: "a.example.com", content: "192.168.1.2", ttl: 3600, prio: 0 }], 130 | "AAAA" => [{ name: "example.com", content: "2001:4860:4860::8888", ttl: nil, prio: 0 }], 131 | "CNAME" => [{ name: "www.example.com", content: "example.com", ttl: nil, prio: 0 }], 132 | "MX" => [{ name: "example.com", content: "mx1.example.com", ttl: nil, prio: 10 }], 133 | "NS" => [{ name: "example.com", content: "ns1.example.com", ttl: nil, prio: 0 }] 134 | }.each do |rtype, records| 135 | assert_equal records, have[rtype], "RRTYPE #{rtype} mismatch" 136 | end 137 | end 138 | 139 | def test_example_org_zone 140 | have = fetch_records("example.org") 141 | 142 | assert_equal [{ 143 | name: "example.org", 144 | ttl: 3600, 145 | prio: 0, 146 | content: [ 147 | "ns1.example.com", 148 | "webmaster.example.com", 149 | 2124123101, 150 | 24 * 3600, # refresh 151 | 3 * 3600, # retry 152 | 7 * 24 * 3600, # expire 153 | 600, # min ttl 154 | ].join("\t"), 155 | }], have.fetch("SOA"), "RRTYPE SOA mismatch" 156 | 157 | { 158 | "A" => [{ name: "a.example.org", content: "192.168.1.3", ttl: 600, prio: 0 }, 159 | { name: "b.example.org", content: "10.11.12.13", ttl: nil, prio: 0 }], 160 | "AAAA" => [{ name: "example.org", content: "2001:4860:4860::6666", ttl: nil, prio: 0 }, 161 | { name: "b.example.org", content: "2001:4860:4860::abcd", ttl: nil, prio: 0 }], 162 | "CNAME" => [{ name: "foo.example.org", content: "a.example.org", ttl: 42, prio: 0 }, 163 | { name: "foo.bar.example.org", content: "a.example.org", ttl: nil, prio: 0 }, 164 | { name: "c.example.org", content: "b.example.org", ttl: 60, prio: 0 }], 165 | "MX" => [{ name: "example.org", content: "mx1.example.org", ttl: nil, prio: 10 }, 166 | { name: "example.org", content: "mx2.example.org", ttl: nil, prio: 20 }], 167 | "NS" => [{ name: "example.org", content: "ns1.example.com", ttl: nil, prio: 0 }], 168 | "TXT" => [{ name: "a.example.org", content: "a=b", ttl: 120, prio: 0 }], 169 | }.each do |rtype, records| 170 | assert_equal records, have[rtype], "RRTYPE #{rtype} mismatch" 171 | end 172 | end 173 | 174 | def test_update_zone_affects_only_updated_zone 175 | hashes = {} 176 | with_db do |db| 177 | db.execute("select name, dnsgit_zone_hash from domains") do |(domain, checksum)| 178 | hashes[domain] = { old: checksum, new: nil } 179 | end 180 | end 181 | 182 | @on_client.join("zones/example.com.rb").open("a") do |z| 183 | z.puts "", "txt 'foo'" 184 | end 185 | commit! 186 | 187 | with_db do |db| 188 | db.execute("select name, dnsgit_zone_hash from domains") do |(domain, checksum)| 189 | hashes[domain][:new] = checksum 190 | end 191 | end 192 | 193 | zone = hashes.fetch("example.org") 194 | refute_includes @push_output, "example.org has been updated" 195 | assert_equal zone[:old], zone[:new], "example.org zone changed (it ought not to)" 196 | 197 | zone = hashes.fetch("example.com") 198 | assert_includes @push_output, "example.com has been updated" 199 | refute_equal zone[:old], zone[:new], "example.com zone did not change (it ought to)" 200 | end 201 | 202 | def test_global_metadata 203 | cfg = YAML.load_file @on_client.join("config.yaml") 204 | cfg["sqlite"]["meta"] = { "notify_dnsupdate" => 1 } 205 | @on_client.join("config.yaml").open("w") {|f| f.write(cfg.to_yaml) } 206 | commit! 207 | 208 | meta = Hash.new {|h,k| h[k] = {} } 209 | with_db do |db| 210 | q = <<~SQL 211 | select domains.name, m.kind, m.content 212 | from domainmetadata m 213 | inner join domains on domains.id = m.domain_id 214 | SQL 215 | db.execute(q) do |name, kind, content| 216 | meta[name][kind] = content 217 | end 218 | end 219 | 220 | expected = { "NOTIFY-DNSUPDATE" => "1" } 221 | assert_equal expected, meta.fetch("example.com") 222 | assert_equal expected, meta.fetch("example.org") 223 | end 224 | 225 | def test_busy_retry 226 | # make DB busy by modifying the DB file in a long running transaction 227 | # TODO: can we simplify this? 228 | t = Thread.new { 229 | SQLite3::Database.new(@db_path.to_s) do |db| 230 | db.transaction { 231 | db.execute "insert into domains(name, type) values ('test-busy.retry', 'MASTER')" 232 | sleep 2 233 | db.execute "delete from domains where name = 'test-busy.retry'" 234 | } 235 | end 236 | } 237 | 238 | # pushing changes should wait for the busy database 239 | @on_client.join("zones/example.com.rb").open("a") do |z| 240 | z.puts "", "txt 'foo'" 241 | end 242 | commit! 243 | t.join 244 | 245 | # Did we actually caught a busy database? Depending on the accuracy 246 | # of the sleep above and the thread scheduling, @push_output sometimes 247 | # contains a "retry 2 of 10". 248 | assert_includes @push_output, "Database is busy, retry 1 of 10 in 1s" 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | require "tmpdir" 3 | require File.expand_path("../lib/environment", __dir__) 4 | 5 | class IntegrationTest < Minitest::Test 6 | def initialize(*) 7 | super 8 | @raw_output = nil 9 | end 10 | 11 | def teardown 12 | puts @raw_output if @raw_output && failures.length > 0 13 | @work.rmtree if @work.exist? 14 | end 15 | 16 | def execute(cmd, *args) 17 | cmd = [cmd, *args].map(&:to_s) 18 | out = IO.popen(cmd, "r", err: [:child, :out], &:read) 19 | unless $?.exitstatus == 0 20 | raise "command failed:\n\tcmd: #{cmd}\n\tpwd: #{Dir.getwd}\n\toutput:\n#{out}" 21 | end 22 | out 23 | end 24 | 25 | # creates a temporary working directory 26 | def mktemp 27 | tmpdir = ENV["DNSGIT_TEMP_DIR"] || (File.exist?("/dev/shm") ? "/dev/shm" : Dir.tmpdir) 28 | Pathname.new Dir.mktmpdir("dnsgit", tmpdir) 29 | end 30 | 31 | def init_dnsgit! 32 | @work = mktemp 33 | @on_server = @work.join("on-server") 34 | @on_client = @work.join("on-client") 35 | 36 | # copy necessary files into tmpwd/on-server 37 | # (cp is faster than `git clone ../ ./on-server`) 38 | root = Pathname.new(__dir__).join("..") 39 | %w[bin lib].each do |ent| 40 | FileUtils.cp_r root.join(ent), @on_server.tap(&:mkpath) 41 | end 42 | 43 | # initialize copy 44 | Dir.chdir @on_server do 45 | execute "bin/init" 46 | end 47 | 48 | # clone a client copy to tmpwd/on-client 49 | Dir.chdir @work do 50 | execute "git", "clone", "on-server/data", "./on-client" 51 | end 52 | 53 | Dir.chdir @on_client do 54 | execute "git", "config", "user.name", "dnsgit" 55 | execute "git", "config", "user.email", "nobody@example.com" 56 | end 57 | 58 | # prepare tmpwd/on-client 59 | @on_client.join("templates").each_child(&:delete) 60 | @on_client.join("zones").each_child(&:delete) 61 | { 62 | "templates/ns.rb" => "templates/", 63 | "zones/0.0.127.in-addr.arpa.rb" => "zones/", 64 | "zones/example.com.rb" => "zones/", 65 | "zones/example.org.rb" => "zones/", 66 | "onupdate.sh" => "", 67 | }.each do |src, dst| 68 | FileUtils.cp root.join("test/fixtures/on-client", src), @on_client.join(dst) 69 | end 70 | 71 | config = yield 72 | @on_client.join("config.yaml").open("w") do |f| 73 | cfg = { 74 | "execute" => ["echo 1", "./onupdate.sh", "echo 2"], 75 | "soa" => { 76 | "primary" => "ns1.example.com.", 77 | "email" => "webmaster@example.com", 78 | "ttl" => "1H", 79 | "refresh" => "1D", 80 | "retry" => "3H", 81 | "expire" => "1W", 82 | "minimumTTL" => "2D", 83 | } 84 | }.merge(config) 85 | f.write cfg.to_yaml 86 | end 87 | 88 | commit! 89 | end 90 | 91 | def commit! 92 | Dir.chdir @on_client do 93 | execute "git", "add", "-A" 94 | execute "git", "commit", "-m", "hook integration test" 95 | @raw_output = execute("git", "push") 96 | @push_output = @raw_output 97 | .gsub(/^remote:\s?(.*?)\s*$/, '\1') # strip git prefix 98 | .gsub(/\e\[\d+(?:;\d)*m/, '') # strip color codes 99 | .gsub(/^(?:DEBUG|INFO|WARN|ERROR).*$\n/, '') # strip DebugLog 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/unit/zone_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "test_helper" 3 | 4 | describe Zone do 5 | subject do 6 | Zone.new("example.com", nil) 7 | end 8 | 9 | describe "soa" do 10 | it "should create host" do 11 | subject.soa ttl: "42m" 12 | _(subject.zonefile.output).must_include %w[42m IN SOA].join("\t") 13 | end 14 | end 15 | 16 | describe "a record" do 17 | it "should create host" do 18 | subject.a "127.0.0.1" 19 | _(subject.zonefile.a).must_equal [{class: "IN", name: "@", ttl: nil, host: "127.0.0.1"}] 20 | end 21 | 22 | it "should create host, ttl" do 23 | subject.a "127.0.0.1", 600 24 | _(subject.zonefile.a).must_equal [{class: "IN", name: "@", ttl: 600, host: "127.0.0.1"}] 25 | end 26 | 27 | it "should create name, host, ttl" do 28 | subject.a "www", "127.0.0.1", 600 29 | _(subject.zonefile.a).must_equal [{class: "IN", name: "www", ttl: 600, host: "127.0.0.1"}] 30 | end 31 | 32 | it "should create name with ipv4 and ipv6" do 33 | subject.a "www", "127.0.0.1", "::ffff:7f00:1" 34 | _(subject.zonefile.a).must_equal [{class: "IN", name: "www", ttl: nil, host: "127.0.0.1"}] 35 | _(subject.zonefile.a4).must_equal [{class: "IN", name: "www", ttl: nil, host: "::ffff:7f00:1"}] 36 | end 37 | 38 | it "should create name with ipv4, ipv6 and TTL" do 39 | subject.a "www", "127.0.0.1", "::ffff:7f00:1", 600 40 | _(subject.zonefile.a).must_equal [{class: "IN", name: "www", ttl: 600, host: "127.0.0.1"}] 41 | _(subject.zonefile.a4).must_equal [{class: "IN", name: "www", ttl: 600, host: "::ffff:7f00:1"}] 42 | end 43 | end 44 | 45 | describe "cname record" do 46 | it "without args" do 47 | assert_raises ArgumentError do 48 | subject.cname 49 | end 50 | end 51 | 52 | it "with name" do 53 | subject.cname "www" 54 | _(subject.zonefile.cname).must_equal [{class: "IN", name: "www", ttl: nil, host: "@"}] 55 | end 56 | 57 | it "with name, host" do 58 | subject.cname "www", "other-server." 59 | _(subject.zonefile.cname).must_equal [{class: "IN", name: "www", ttl: nil, host: "other-server."}] 60 | end 61 | 62 | it "with name, ttl" do 63 | subject.cname "www", 600 64 | _(subject.zonefile.cname).must_equal [{class: "IN", name: "www", ttl: 600, host: "@"}] 65 | end 66 | 67 | it "with name, ttl, host" do 68 | subject.cname "www", "other-server.", 600 69 | _(subject.zonefile.cname).must_equal [{class: "IN", name: "www", ttl: 600, host: "other-server."}] 70 | end 71 | end 72 | 73 | describe "nesting records" do 74 | it "under a" do 75 | subject.a "test", "127.0.0.1" do 76 | cname "www" 77 | cname "www2", 600 78 | txt "yada" 79 | txt "yada-yada", 60 80 | end 81 | 82 | _(subject.zonefile.a).must_equal [{class: "IN", name: "test", ttl: nil, host: "127.0.0.1"}] 83 | _(subject.zonefile.cname).must_equal [{class: "IN", name: "www", ttl: nil, host: "test"}, 84 | {class: "IN", name: "www2", ttl: 600, host: "test"}] 85 | _(subject.zonefile.txt).must_equal [{class: "IN", name: "test", ttl: nil, text: "yada"}, 86 | {class: "IN", name: "test", ttl: 60, text: "yada-yada"}] 87 | end 88 | end 89 | 90 | describe "mx record" do 91 | it "should create without args" do 92 | subject.mx 93 | _(subject.zonefile.mx).must_equal [{class: "IN", name: "@", ttl: nil, host: "@", pri: 10}] 94 | end 95 | 96 | it "should create with host" do 97 | subject.mx "mail" 98 | _(subject.zonefile.mx).must_equal [{class: "IN", name: "@", ttl: nil, host: "mail", pri: 10}] 99 | end 100 | 101 | it "should create with host, priority" do 102 | subject.mx "mail", 20 103 | _(subject.zonefile.mx).must_equal [{class: "IN", name: "@", ttl: nil, host: "mail", pri: 20}] 104 | end 105 | 106 | it "should create with host, priority, ttl " do 107 | subject.mx "mail", 20, 600 108 | _(subject.zonefile.mx).must_equal [{class: "IN", name: "@", ttl: 600, host: "mail", pri: 20}] 109 | end 110 | end 111 | 112 | describe "srv record" do 113 | it "without port" do 114 | assert_raises ArgumentError do 115 | subject.srv :ldap, :tcp, "ldap01" 116 | end 117 | end 118 | 119 | it "should create srv record" do 120 | subject.srv :ldap, :tcp, "ldap01", 389 121 | _(subject.zonefile.srv).must_equal [{class: "IN", name: "_ldap._tcp", ttl: nil, pri: 10, weight: 0, host: "ldap01", port: 389}] 122 | end 123 | 124 | it "should create srv record with ttl" do 125 | subject.srv :ldap, :tcp, "ldap01", 389, 600 126 | _(subject.zonefile.srv).must_equal [{class: "IN", name: "_ldap._tcp", ttl: 600, pri: 10, weight: 0, host: "ldap01", port: 389}] 127 | end 128 | 129 | it "should create srv record with pri and weight" do 130 | subject.srv :ldap, :tcp, "ldap01", 389, pri: 15, weight: 3 131 | _(subject.zonefile.srv).must_equal [{class: "IN", name: "_ldap._tcp", ttl: nil, pri: 15, weight: 3, host: "ldap01", port: 389}] 132 | end 133 | 134 | it "should create srv record with name" do 135 | subject.srv "foo", :ldap, :tcp, "ldap01", 389 136 | _(subject.zonefile.srv).must_equal [{class: "IN", name: "_ldap._tcp.foo", ttl: nil, pri: 10, weight: 0, host: "ldap01", port: 389}] 137 | end 138 | end 139 | 140 | describe "ptr record" do 141 | it "should create ptr record" do 142 | subject.ptr 127, "foobar.org" 143 | _(subject.zonefile.ptr).must_equal [{class: "IN", name: "127", ttl: nil, host: "foobar.org."}] 144 | end 145 | end 146 | 147 | describe "txt record" do 148 | it "should create txt record" do 149 | subject.txt "site-verification-token" 150 | _(subject.zonefile.txt).must_equal [{class: "IN", name: "@", ttl: nil, text: "site-verification-token"}] 151 | end 152 | 153 | it "should create txt record, with ttl" do 154 | subject.txt "site-verification-token", 60 155 | _(subject.zonefile.txt).must_equal [{class: "IN", name: "@", ttl: 60, text: "site-verification-token"}] 156 | end 157 | 158 | it "should create txt record, with host" do 159 | subject.txt "example.com.", "site-verification-token" 160 | _(subject.zonefile.txt).must_equal [{class: "IN", name: "example.com.", ttl: nil, text: "site-verification-token"}] 161 | end 162 | 163 | it "should create txt record, with ttl, host" do 164 | subject.txt "example.com.", "site-verification-token", 120 165 | _(subject.zonefile.txt).must_equal [{class: "IN", name: "example.com.", ttl: 120, text: "site-verification-token"}] 166 | end 167 | end 168 | 169 | describe "ptr6 record" do 170 | it "with a double colon" do 171 | assert_raises ArgumentError do 172 | subject.ptr6 "1319::1", "example.com" 173 | end 174 | end 175 | 176 | it "should create ptr record" do 177 | subject.ptr6 "1319:8a2e:0370:7344", "example.com" 178 | _(subject.zonefile.ptr).must_equal [{class: "IN", name: "4.4.3.7.0.7.3.0.e.2.a.8.9.1.3.1", ttl: nil, host: "example.com."}] 179 | end 180 | 181 | it "should create ptr record with filled zeros" do 182 | subject.ptr6 "1319:8a2e:70:1", "example.com" 183 | _(subject.zonefile.ptr).must_equal [{class: "IN", name: "1.0.0.0.0.7.0.0.e.2.a.8.9.1.3.1", ttl: nil, host: "example.com."}] 184 | end 185 | end 186 | 187 | describe "tlsa record" do 188 | it "should create tlsa record" do 189 | subject.tlsa "www", 443, :tcp, 3, 0, 1, "e31d9e402c6308273375b68297f7af207521238f0cd812622672f0f2ce67eb1c" 190 | _(subject.zonefile.tlsa).must_equal [{ 191 | class: "IN", 192 | name: "_443._tcp.www", 193 | ttl: nil, 194 | certificate_usage: 3, 195 | selector: 0, 196 | matching_type: 1, 197 | data: "e31d9e402c6308273375b68297f7af207521238f0cd812622672f0f2ce67eb1c" 198 | }] 199 | end 200 | 201 | it "should create tlsa record without subdomain" do 202 | subject.tlsa 443, :tcp, 3, 0, 1, "e31d9e402c6308273375b68297f7af207521238f0cd812622672f0f2ce67eb1c", 3600 203 | _(subject.zonefile.tlsa).must_equal [{ 204 | class: "IN", 205 | name: "_443._tcp", 206 | ttl: 3600, 207 | certificate_usage: 3, 208 | selector: 0, 209 | matching_type: 1, 210 | data: "e31d9e402c6308273375b68297f7af207521238f0cd812622672f0f2ce67eb1c" 211 | }] 212 | end 213 | 214 | it "with invalid port" do 215 | assert_raises ArgumentError do 216 | subject.tlsa "www", 123456, :tcp, 3, 0, 1, "e31d9e402c6308273375b68297f7af207521238f0cd812622672f0f2ce67eb1c" 217 | end 218 | end 219 | end 220 | end 221 | --------------------------------------------------------------------------------