├── lib ├── zonefile.rb └── zonefile │ └── zonefile.rb ├── .gitignore ├── CHANGELOG ├── zonefile.gemspec ├── LICENSE ├── README.md └── tests ├── test-zone.db └── zonefile.rb /lib/zonefile.rb: -------------------------------------------------------------------------------- 1 | require 'zonefile/zonefile' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS X 2 | .DS_Store 3 | 4 | ## Compiled Gems 5 | *.gem 6 | 7 | ## Backup Files 8 | *~ 9 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 1.00 - Initial Release 2 | 1.01 - Fixes 3 | 1.02 - Fixes 4 | 1.03 - Fixes TXT records, quotes are not treated anymore 5 | 1.05 - Adds support for TLSA records 6 | -------------------------------------------------------------------------------- /zonefile.gemspec: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | 3 | SPEC = Gem::Specification.new do |s| 4 | 5 | s.name = 'zonefile' 6 | s.version = '1.07' 7 | s.author = 'Martin Boese' 8 | s.email = 'martin.boese@ita.ao' 9 | s.rubyforge_project = 'zonefile' 10 | s.homepage = 'https://github.com/boesemar/zonefile' 11 | s.license = 'MIT' 12 | s.platform = Gem::Platform::RUBY 13 | s.summary = 'BIND Zonefile Reader and Writer' 14 | s.description = "A library that can create, read, write, modify BIND compatible Zonefiles (RFC1035).\n"+ 15 | "Warning: It probably works for most cases, but it might not be able to read all files \n"+ 16 | "even if they are valid for bind." 17 | candidates = Dir.glob("{lib,tests}/**/*") << "CHANGELOG" 18 | s.files = candidates.delete_if { |item|item.include?("~") } 19 | s.require_path = 'lib' 20 | s.has_rdoc = true 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Martin Boese 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to 7 | deal in the Software without restriction, including without limitation the 8 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | sell copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby Zonefile - Parse and manipulate DNS Zone Files. 2 | 3 | ## Description 4 | 5 | This class can read, manipulate and create DNS zone files. The data can be accessed by the instance method of the same name. All except SOA return an array of hashes containing the named data. SOA directly turns the hash since there can only be one SOA information. 6 | 7 | The following hash keys are returned per record type: 8 | 9 | * SOA 10 | - :ttl, :primary, :email, :serial, :refresh, :retry, :expire, :minimumTTL 11 | * A 12 | - :name, :ttl, :class, :host 13 | * MX 14 | - :name, :ttl, :class, :pri, :host 15 | * NS 16 | - :name, :ttl, :class, :host 17 | * CNAME 18 | - :name, :ttl, :class, :host 19 | * TXT 20 | - :name, :ttl, :class, :text 21 | * A4 (AAAA) 22 | - :name, :ttl, :class, :host 23 | * PTR 24 | - :name, :ttl, :class, :host 25 | * SRV 26 | - :name, :ttl, :class, :pri, :weight, :port, :host 27 | * DS 28 | - :name, :ttl, :class, :key_tag, :algorithm, :digest_type, :digest 29 | * DNSKEY 30 | - :name, :ttl, :class, :flag, :protocol, :algorithm, :public_key 31 | * RRSIG 32 | - :name, :ttl, :class, :type_covered, :algorithm, :labels, :original_ttl, 33 | :expiration, :inception, :key_tag, :signer, :signature 34 | * NSEC 35 | - :name, :ttl, :class, :next, :types 36 | * NSEC3 37 | - :name, :ttl, :class, :algorithm, :flags, :iterations, :salt, :next, :types 38 | * NSEC3PARAM 39 | - :name, :ttl, :class, :algorithm, :flags, :iterations, :salt 40 | * TLSA 41 | - :name, :ttl, :class, :certificate_usage, :selector, :matching_type, :data 42 | * NAPTR 43 | - :name, :ttl, :class, :order, :preference, :flags, :service, :regexp, :replacement 44 | * SPF 45 | - :name, :ttl, :class, :text 46 | * CAA 47 | - :name, :ttl, :class, :flag, :tag, :value 48 | # Examples 49 | 50 | ## Read a Zonefile 51 | 52 | zf = Zonefile.from_file('/path/to/zonefile.db') 53 | 54 | # Display MX-Records 55 | zf.mx.each do |mx_record| 56 | puts "Mail Exchagne with priority: #{mx_record[:pri]} --> #{mx_record[:host]}" 57 | end 58 | 59 | # Show SOA TTL 60 | puts "Record Time To Live: #{zf.soa[:ttl]}" 61 | 62 | # Show A-Records 63 | zf.a.each do |a_record| 64 | puts "#{a_record[:name]} --> #{a_record[:host]}" 65 | end 66 | 67 | ## Manipulate a Zonefile 68 | 69 | zf = Zonefile.from_file('/path/to/zonefile.db') 70 | 71 | # Change TTL and add an A-Record 72 | 73 | zf.soa[:ttl] = '123123' # Change the SOA ttl 74 | zf.a << { :class => 'IN', :name => 'www', :host => '192.168.100.1', :ttl => 3600 } # add A-Record 75 | 76 | # Setting PTR records (deleting existing ones) 77 | 78 | zf.ptr = [ { :class => 'IN', :name=>'1.100.168.192.in-addr.arpa', :host => 'my.host.com' }, 79 | { :class => 'IN', :name=>'2.100.168.192.in-addr.arpa', :host => 'me.host.com' } ] 80 | 81 | # Increase Serial Number 82 | zf.new_serial 83 | 84 | # Print new zonefile 85 | puts "New Zonefile: \n#{zf.output}" 86 | 87 | # Name attribute magic 88 | 89 | The :name attribute is preserved and returned as defined in a previous record if a zonefile entry 90 | omits it. This should be the expected behavior for most users. 91 | You can switch this off globally by calling Zonefile.preserve_name(false) 92 | 93 | # License 94 | 95 | MIT License 96 | 97 | # Authors 98 | 99 | Martin Boese, based on Simon Flack Perl library DNS::ZoneParse 100 | 101 | Andy Newton, patch to support various additional records 102 | 103 | Julian Kornberger, patch to support TLSA records 104 | 105 | Nic P, SPF support 106 | 107 | Leon M, Fixes 108 | -------------------------------------------------------------------------------- /tests/test-zone.db: -------------------------------------------------------------------------------- 1 | ; Database file dns-zoneparse-test.net.dns for dns-zoneparse-test.net zone. 2 | ; Zone version: 2000100501 3 | $TTL 1H 4 | $ORIGIN test-zone.db 5 | @ 3600 IN SOA ns0.dns-zoneparse-test.net. support.dns-zoneparse-test.net. ( 6 | 2000100501 ; serial number 7 | 10800 ; refresh 8 | 3600 ; retry 9 | 691200 ; expire 10 | 86400 ) ; minimum TTL 11 | 12 | 43200 IN NS ns0.dns-zoneparse-test.net. ; ( A multi line 13 | comment ) 14 | @ IN NS ns1.dns-zoneparse-test.net. 15 | 16 | @ IN A 127.0.0.1 17 | @ IN MX 10 mail 18 | @ IN SPF "v=spf1 mx ~all" 19 | ftp IN CNAME www 20 | localhost IN A 127.0.0.1 21 | mail IN A 127.0.0.1 22 | www IN A 127.0.0.1 23 | in a 10.0.0.2 24 | 43200 IN A 10.0.0.3 25 | IN MX 10 10.0.0.4 26 | A 10.0.0.5 27 | TXT "web;server" 28 | SPF "v=spf1 -all" 29 | foo IN A 10.0.0.6 30 | mini A 10.0.0.7 31 | icarus IN AAAA fe80::0260:83ff:fe7c:3a2a 32 | soup IN TXT "This is a text message" 33 | txta TXT "t=y; o=-" ; Nasty Comment 34 | elsewhere IN SPF "v=spf1 mx ?all" 35 | _kerberos IN TXT maxnet.ao 36 | _sip._tcp.example.com. 86400 IN SRV 0 5 5060 sipserver.example.com. 37 | 12.23.21.23.in-addr.arpa IN PTR www.myhost.example.com. 38 | ds1 IN DS 31528 5 1 2274EACD70C5CD6862E1C0262E99D48D9FDEC271 39 | ds2 IN DS 31528 5 1 ( 2BB183AF5F22588179A53B0A 40 | 98631FAD1A292118 ) 41 | example.com. 86400 IN DNSKEY 256 3 5 ( AQPSKmynfzW4kyBv015MUG2DeIQ3 42 | Cbl+BBZH4b/0PY1kxkmvHjcZc8no 43 | kfzj31GajIQKY+5CptLr3buXA10h 44 | WqTkF7H6RfoRqXQeogmMHfpftf6z 45 | Mv1LyBUgia7za6ZEzOJBOztyvhjL 46 | 742iU/TpPSEDhm2SNKLijfUppn1U 47 | aNvv4w== ) 48 | example.net. 86400 IN DNSKEY 256 3 5 AQPSKmynfzW4kyBv015MUG2DeIQ3Cbl+BBZH4b/0PY1kxkmvHjcZc8nokfzj31GajIQKY+5CptLr3buXA10hWqTkF7H6RfoRqXQeogmMHfpftf6zMv1LyBUgia7za6ZEzOJBOztyvhjL742iU/TpPSEDhm2SNKLijfUppn1UaNvv4w== 49 | host.example.com. 86400 IN RRSIG A 5 3 86400 20030322173103 ( 50 | 20030220173103 2642 example.com. 51 | oJB1W6WNGv+ldvQ3WDG0MQkg5IEhjRip8WTr 52 | PYGv07h108dUKGMeDPKijVCHX3DDKdfb+v6o 53 | B9wfuh3DTJXUAfI/M0zmO/zz8bW0Rznl8O3t 54 | GNazPwQKkRN20XPXV6nwwfoXmJQbsLNrLfkG 55 | J5D6fwFm8nN+6pBzeDQfsS3Ap3o= ) 56 | alfa.example.com. 86400 IN NSEC host.example.com. ( 57 | A MX RRSIG NSEC TYPE1234 ) 58 | IN NSEC3 1 1 12 aabbccdd ( 2vptu5timamqttgl4luu7kg2leoaor3s A RRSIG ) 59 | IN NSEC3PARAM 1 0 12 aabbccdd 60 | _443._tcp.www.example.com. 86400 IN TLSA ( 61 | 1 1 2 92003ba34942dc74152e2f2c408d29ec 62 | a5a520e7f2e06bb944f4dca346baf63c 63 | 1b177615d466f6c4b71c216a50292bd5 64 | 8c9ebdd2f74e38fe51ffd48c43326cbc ) 65 | urn.example.com. IN NAPTR 100 50 "s" "http+N2L+N2C+N2R" "" www.example.com. 66 | 67 | @ IN CAA 0 issue "caa.example.com" 68 | example.com. IN CAA 1 iodef "mailto:security@example.com" 69 | example.com. IN CAA 2 iodef "http://iodef.example.com/" -------------------------------------------------------------------------------- /tests/zonefile.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | 3 | 4 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib') 5 | 6 | require 'zonefile' 7 | 8 | # Zonefile.preserve_name(false) 9 | 10 | $zonefile = ARGV[0] || 'test-zone.db' 11 | 12 | class TC_Zonefile < Minitest::Test 13 | 14 | def setup 15 | @zf = Zonefile.from_file(File.dirname(__FILE__) + '/'+$zonefile, 'test-origin') 16 | end 17 | 18 | def swap # generate output and re-read @zf from it 19 | @zf = Zonefile.new(@zf.output, 'test-origin') 20 | end 21 | 22 | def test_empty 23 | zf = Zonefile.new 24 | zf.soa[:refresh] = 1234 25 | assert zf.empty? 26 | end 27 | 28 | def test_setter 29 | data = [ { :class => 'IN', :name => '123', :host => 'test' }, 30 | { :name => '321', :hosts => 'test2' } ] 31 | @zf.ptr = data 32 | assert_equal 2, @zf.ptr.size 33 | assert @zf.ptr[0][:host] == data[0][:host] 34 | assert @zf.ptr[1][:name] == data[1][:name] 35 | assert_raises(NoMethodError) do 36 | @zf.dont_exist(123,123,123) 37 | end 38 | end 39 | 40 | 41 | def test_soa 42 | assert_equal '86400', @zf.soa[:minimumTTL] 43 | assert_equal '691200', @zf.soa[:expire] 44 | assert_equal '3600', @zf.soa[:retry] 45 | assert_equal '10800', @zf.soa[:refresh] 46 | assert_equal '2000100501', @zf.soa[:serial] 47 | assert_equal 'support.dns-zoneparse-test.net.', @zf.soa[:email] 48 | assert_equal 'ns0.dns-zoneparse-test.net.', @zf.soa[:primary] 49 | 50 | begin 51 | @swap_soa = true 52 | swap 53 | test_soa 54 | end unless @swap_soa 55 | end 56 | 57 | def test_a 58 | assert_equal 9, @zf.a.size 59 | assert_equal 'mini', @zf.a.last[:name] 60 | assert_equal '10.0.0.7', @zf.a.last[:host] 61 | assert_equal '127.0.0.1', @zf.a.first[:host] 62 | 63 | a = @zf.a.find { |a| a[:host] == '10.0.0.3'} 64 | assert_equal '43200', a[:ttl] 65 | assert_equal 'www', a[:name].to_s # name preserved 66 | 67 | begin 68 | @swap_a = true 69 | swap 70 | test_a 71 | end unless @swap_a 72 | end 73 | 74 | def test_preserve_name 75 | Zonefile.preserve_name(false) 76 | setup 77 | a = @zf.a.find { |a| a[:host] == '10.0.0.2'} 78 | assert_nil a[:name] # no name preserved 79 | assert_nil @zf.nsec3[0][:name] # same here 80 | Zonefile.preserve_name(true) 81 | setup 82 | a = @zf.a.find { |a| a[:host] == '10.0.0.2'} 83 | assert_equal 'www', a[:name] # now name IS preserved 84 | assert_equal 'alfa.example.com.', @zf.nsec3[0][:name] # same here 85 | end 86 | 87 | def test_mx 88 | assert_equal 2, @zf.mx.size 89 | assert_equal 10, @zf.mx.first[:pri] 90 | begin 91 | @swap_mx = true 92 | swap 93 | test_mx 94 | end unless @swap_mx 95 | end 96 | 97 | def test_cname 98 | assert !!@zf.cname.find { |e| e[:host] == 'www' } 99 | begin 100 | @swap_cname = true 101 | swap 102 | test_cname 103 | end unless @swap_cname 104 | end 105 | 106 | def test_ns 107 | assert_equal 'ns0.dns-zoneparse-test.net.', @zf.ns[0][:host] 108 | assert_equal 'ns1.dns-zoneparse-test.net.', @zf.ns[1][:host] 109 | begin 110 | @swap_ns = true 111 | swap 112 | test_ns 113 | end unless @swap_ns 114 | end 115 | 116 | def test_txt 117 | assert_equal '"web;server"', @zf.txt[0][:text] 118 | assert_equal 'IN', @zf.txt[1][:class] 119 | assert_equal 'soup', @zf.txt[1][:name] 120 | assert_equal 'txta', @zf.txt[2][:name] 121 | assert_equal 'IN', @zf.txt[3][:class] 122 | assert_equal "\"t=y; o=-\"", @zf.txt[2][:text] 123 | assert_equal 'maxnet.ao', @zf.txt[3][:text] 124 | assert_equal '_kerberos', @zf.txt[3][:name] 125 | 126 | assert_equal 4, @zf.txt.size 127 | begin 128 | @swap_txt = true 129 | swap 130 | test_txt 131 | end unless @swap_txt 132 | end 133 | 134 | def test_spf 135 | assert_equal '"v=spf1 mx ~all"', @zf.spf[0][:text] 136 | assert_equal "IN", @zf.spf[0][:class] 137 | assert_equal "@", @zf.spf[0][:name] 138 | assert_equal '"v=spf1 -all"', @zf.spf[1][:text] 139 | assert_equal 'www', @zf.spf[1][:name] 140 | assert_nil @zf.spf[1][:class] 141 | assert_equal "elsewhere", @zf.spf[2][:name] 142 | assert_equal '"v=spf1 mx ?all"', @zf.spf[2][:text] 143 | 144 | assert_equal 3, @zf.spf.size 145 | 146 | begin 147 | @swap_txt = true 148 | swap 149 | test_txt 150 | end unless @swap_txt 151 | end 152 | 153 | 154 | def test_a4 155 | assert_equal 'icarus', @zf.a4[0][:name] 156 | assert_equal 'IN', @zf.a4[0][:class] 157 | assert_equal 1, @zf.a4.size 158 | assert_equal 'fe80::0260:83ff:fe7c:3a2a', @zf.a4[0][:host] 159 | begin 160 | @swap_a4 = true 161 | swap 162 | test_a4 163 | end unless @swap_a4 164 | end 165 | 166 | def test_srv 167 | assert_equal '_sip._tcp.example.com.', @zf.srv[0][:name] 168 | assert_equal '86400', @zf.srv[0][:ttl] 169 | assert_equal '0', @zf.srv[0][:pri] 170 | assert_equal '5', @zf.srv[0][:weight] 171 | assert_equal '5060', @zf.srv[0][:port] 172 | assert_equal 'sipserver.example.com.', @zf.srv[0][:host] 173 | begin 174 | @swap_srv = true 175 | swap 176 | test_srv 177 | end unless @swap_srv 178 | end 179 | 180 | def test_serial_generator 181 | old = @zf.soa[:serial] 182 | new = @zf.new_serial 183 | assert new.to_i > old.to_i 184 | newer = @zf.new_serial 185 | assert newer.to_i - 1, new 186 | 187 | @zf.soa[:serial] = '9999889901' 188 | @zf.new_serial 189 | assert_equal '9999889902', @zf.soa[:serial] 190 | end 191 | 192 | def test_ptr 193 | assert_equal '12.23.21.23.in-addr.arpa', @zf.ptr[0][:name] 194 | assert_equal 'www.myhost.example.com.', @zf.ptr[0][:host] 195 | begin 196 | @swap_ptr = true 197 | swap 198 | test_ptr 199 | end unless @swap_ptr 200 | end 201 | 202 | def test_ds 203 | assert_equal "ds1", @zf.ds[0][:name] 204 | assert_equal 31528, @zf.ds[0][:key_tag] 205 | assert_equal "5", @zf.ds[0][:algorithm] 206 | assert_equal 1, @zf.ds[0][:digest_type] 207 | assert_equal "2274EACD70C5CD6862E1C0262E99D48D9FDEC271", @zf.ds[0][:digest] 208 | assert_equal "ds2", @zf.ds[1][:name] 209 | assert_equal 31528, @zf.ds[1][:key_tag] 210 | assert_equal "5", @zf.ds[1][:algorithm] 211 | assert_equal 1, @zf.ds[1][:digest_type] 212 | assert_equal "2BB183AF5F22588179A53B0A98631FAD1A292118", @zf.ds[1][:digest] 213 | begin 214 | @swap_ds = true 215 | swap 216 | test_ds 217 | end unless @swap_ds 218 | end 219 | 220 | def test_nsec 221 | assert_equal "alfa.example.com.", @zf.nsec[0][:name] 222 | assert_equal "host.example.com.", @zf.nsec[0][:next] 223 | assert_equal "A MX RRSIG NSEC TYPE1234", @zf.nsec[0][:types] 224 | begin 225 | @swap_nsec = true 226 | swap 227 | test_nsec 228 | end unless @swap_nsec 229 | end 230 | 231 | def test_caa 232 | assert_equal "IN", @zf.caa[0][:class] 233 | assert_equal "IN", @zf.caa[1][:class] 234 | assert_equal "IN", @zf.caa[2][:class] 235 | 236 | 237 | assert_equal "@", @zf.caa[0][:name] 238 | assert_equal "example.com.", @zf.caa[1][:name] 239 | assert_equal "example.com.", @zf.caa[2][:name] 240 | 241 | assert_equal 0, @zf.caa[0][:flag] 242 | assert_equal 1, @zf.caa[1][:flag] 243 | assert_equal 2, @zf.caa[2][:flag] 244 | 245 | 246 | assert_equal 'issue', @zf.caa[0][:tag] 247 | assert_equal 'iodef', @zf.caa[1][:tag] 248 | assert_equal 'iodef', @zf.caa[2][:tag] 249 | 250 | assert_equal '"caa.example.com"', @zf.caa[0][:value] 251 | assert_equal '"mailto:security@example.com"', @zf.caa[1][:value] 252 | assert_equal '"http://iodef.example.com/"', @zf.caa[2][:value] 253 | begin 254 | @swap_caa = true 255 | swap 256 | test_caa 257 | end unless @swap_caa 258 | end 259 | 260 | def test_nsec3 261 | assert_equal "1", @zf.nsec3[0][:algorithm] 262 | assert_equal "1", @zf.nsec3[0][:flags] 263 | assert_equal "12", @zf.nsec3[0][:iterations] 264 | assert_equal "aabbccdd", @zf.nsec3[0][:salt] 265 | assert_equal "2vptu5timamqttgl4luu7kg2leoaor3s", @zf.nsec3[0][:next] 266 | assert_equal "A RRSIG", @zf.nsec3[0][:types] 267 | begin 268 | @swap_nsec3 = true 269 | swap 270 | test_nsec3 271 | end unless @swap_nsec3 272 | end 273 | 274 | def test_nsec3param 275 | assert_equal "1", @zf.nsec3param[0][:algorithm] 276 | assert_equal "0", @zf.nsec3param[0][:flags] 277 | assert_equal "12", @zf.nsec3param[0][:iterations] 278 | assert_equal "aabbccdd", @zf.nsec3param[0][:salt] 279 | begin 280 | @swap_nsec3param = true 281 | swap 282 | test_nsec3param 283 | end unless @swap_nsec3param 284 | end 285 | 286 | def test_naptr 287 | assert_equal "urn.example.com.", @zf.naptr[0][:name] 288 | assert_equal 100, @zf.naptr[0][:order] 289 | assert_equal 50, @zf.naptr[0][:preference] 290 | assert_equal "\"s\"", @zf.naptr[0][:flags] 291 | assert_equal "\"http+N2L+N2C+N2R\"", @zf.naptr[0][:service] 292 | assert_equal "\"\"", @zf.naptr[0][:regexp] 293 | assert_equal "www.example.com.", @zf.naptr[0][:replacement] 294 | begin 295 | @swap_natpr = true 296 | swap 297 | test_naptr 298 | end unless @swap_natpr 299 | end 300 | 301 | def test_dnskey 302 | assert_equal "example.com.", @zf.dnskey[0][:name] 303 | assert_equal 256, @zf.dnskey[0][:flag] 304 | assert_equal 3, @zf.dnskey[0][:protocol] 305 | assert_equal "5", @zf.dnskey[0][:algorithm] 306 | pkey = < #{mx_record[:host]}" 60 | # end 61 | # 62 | # # Show SOA TTL 63 | # puts "Record Time To Live: #{zf.soa[:ttl]}" 64 | # 65 | # # Show A-Records 66 | # zf.a.each do |a_record| 67 | # puts "#{a_record[:name]} --> #{a_record[:host]}" 68 | # end 69 | # 70 | # 71 | # ==== Manipulate a Zonefile 72 | # 73 | # zf = Zonefile.from_file('/path/to/zonefile.db') 74 | # 75 | # # Change TTL and add an A-Record 76 | # 77 | # zf.soa[:ttl] = '123123' # Change the SOA ttl 78 | # zf.a << { :class => 'IN', :name => 'www', :host => '192.168.100.1', :ttl => 3600 } # add A-Record 79 | # 80 | # # Setting PTR records (deleting existing ones) 81 | # 82 | # zf.ptr = [ { :class => 'IN', :name=>'1.100.168.192.in-addr.arpa', :host => 'my.host.com' }, 83 | # { :class => 'IN', :name=>'2.100.168.192.in-addr.arpa', :host => 'me.host.com' } ] 84 | # 85 | # # Increase Serial Number 86 | # zf.new_serial 87 | # 88 | # # Print new zonefile 89 | # puts "New Zonefile: \n#{zf.output}" 90 | # 91 | # == Name attribute magic 92 | # 93 | # Since 1.04 the :name attribute is preserved and returned as defined in a previous record if a zonefile entry 94 | # omits it. This should be the expected behavior for most users. 95 | # You can switch this off globally by calling Zonefile.preserve_name(false) 96 | # 97 | # == Authors 98 | # 99 | # Martin Boese, based on Simon Flack Perl library DNS::ZoneParse 100 | # 101 | # Andy Newton, patch to support various additional records 102 | # 103 | 104 | class Zonefile 105 | 106 | RECORDS = %w{ mx a a4 ns cname txt ptr srv soa ds dnskey rrsig nsec nsec3 nsec3param tlsa naptr spf caa } 107 | attr :records 108 | attr :soa 109 | attr :data 110 | # global $ORIGIN option 111 | attr :origin 112 | # global $TTL option 113 | attr :ttl 114 | 115 | @@preserve_name = true 116 | 117 | # For compatibility: This can switches off copying of the :name from the 118 | # previous record in a zonefile if found omitted. 119 | # This was zonefile's behavior in <= 1.03 . 120 | def self.preserve_name(do_preserve_name) 121 | @@preserve_name = do_preserve_name 122 | end 123 | 124 | def method_missing(m, *args) 125 | mname = m.to_s.sub("=","") 126 | return super unless RECORDS.include?(mname) 127 | 128 | if m.to_s[-1].chr == '=' then 129 | @records[mname.intern] = args.first 130 | @records[mname.intern] 131 | else 132 | @records[m] 133 | end 134 | end 135 | 136 | 137 | # Compact a zonefile content - removes empty lines, comments, 138 | # converts tabs into spaces etc... 139 | def self.simplify(zf) 140 | # concatenate everything split over multiple lines in parentheses - remove ;-comments in block 141 | zf = zf.gsub(/(\([^\)]*?\))/) { |m| m.split(/\n/).map { |l| l.gsub(/\;.*$/, '') }.join("\n").gsub(/[\r\n]/, '').gsub( /[\(\)]/, '') } 142 | 143 | zf.split(/\n/).map do |line| 144 | r = line.gsub(/\t/, ' ') 145 | r = r.gsub(/\s+/, ' ') 146 | # FIXME: this is ugly and not accurate, couldn't find proper regex: 147 | # Don't strip ';' if it's quoted. Happens a lot in TXT records. 148 | (0..(r.length - 1)).find_all { |i| r[i].chr == ';' }.each do |comment_idx| 149 | if !r[(comment_idx+1)..-1].index(/['"]/) then 150 | r = r[0..(comment_idx-1)] 151 | break 152 | end 153 | end 154 | r 155 | end.delete_if { |line| line.empty? || line[0].chr == ';'}.join("\n") 156 | end 157 | 158 | 159 | # create a new zonefile object by passing the content of the zonefile 160 | def initialize(zonefile = '', file_name= nil, origin= nil) 161 | @data = zonefile 162 | @filename = file_name 163 | @origin = origin || (file_name ? file_name.split('/').last : '') 164 | 165 | @records = {} 166 | @soa = {} 167 | RECORDS.each { |r| @records[r.intern] = [] } 168 | parse 169 | end 170 | 171 | # True if no records (except sao) is defined in this file 172 | def empty? 173 | RECORDS.each do |r| 174 | return false unless @records[r.intern].empty? 175 | end 176 | true 177 | end 178 | 179 | # Create a new object by reading the content of a file 180 | def self.from_file(file_name, origin = nil) 181 | Zonefile.new(File.read(file_name), file_name.split('/').last, origin) 182 | end 183 | 184 | def add_record(type, data= {}) 185 | if @@preserve_name then 186 | @lastname = data[:name] if data[:name].to_s != '' 187 | data[:name] = @lastname if data[:name].to_s == '' 188 | end 189 | @records[type.downcase.intern] << data 190 | end 191 | 192 | # Generates a new serial number in the format of YYYYMMDDII if possible 193 | def new_serial 194 | base = "%04d%02d%02d" % [Time.now.year, Time.now.month, Time.now.day ] 195 | 196 | if ((@soa[:serial].to_i / 100) > base.to_i) then 197 | ns = @soa[:serial].to_i + 1 198 | @soa[:serial] = ns.to_s 199 | return ns.to_s 200 | end 201 | 202 | ii = 0 203 | while (("#{base}%02d" % ii).to_i <= @soa[:serial].to_i) do 204 | ii += 1 205 | end 206 | @soa[:serial] = "#{base}%02d" % ii 207 | end 208 | 209 | def parse_line(line) 210 | valid_name = /[\@a-z_\-\.0-9\*]+/i 211 | valid_ip6 = /[\@a-z_\-\.0-9\*:]+/i 212 | rr_class = /\b(?:IN|HS|CH)\b/i 213 | rr_type = /\b(?:NS|A|CNAME)\b/i 214 | rr_ttl = /(?:\d+[wdhms]?)+/i 215 | ttl_cls = Regexp.new("(?:(#{rr_ttl})\s)?(?:(#{rr_class})\s)?") 216 | base64 = /([\s\w\+\/]*=*)/i 217 | hexadeimal = /([\sA-F0-9]*)/i 218 | quoted = /(\"[^\"]*\")/i 219 | 220 | data = {} 221 | if line =~ /^\$ORIGIN\s*(#{valid_name})/ix then 222 | @origin = $1 223 | elsif line =~ /^(#{valid_name})? \s* 224 | #{ttl_cls} 225 | (#{rr_type}) \s 226 | (#{valid_name}) 227 | /ix then 228 | (name, ttl, dclass, type, host) = [$1, $2, $3, $4, $5] 229 | add_record($4, :name => $1, :ttl => $2, :class => $3, :host => $5) 230 | elsif line=~/^(#{valid_name})? \s* 231 | #{ttl_cls} 232 | AAAA \s 233 | (#{valid_ip6}) 234 | /x then 235 | add_record('a4', :name => $1, :ttl => $2, :class => $3, :host => $4) 236 | elsif line=~/^(#{valid_name})? \s* 237 | #{ttl_cls} 238 | MX \s 239 | (\d+) \s 240 | (#{valid_name}) 241 | /ix then 242 | add_record('mx', :name => $1, :ttl => $2, :class => $3, :pri => $4.to_i, :host => $5) 243 | elsif line=~/^(#{valid_name})? \s* 244 | #{ttl_cls} 245 | SRV \s 246 | (\d+) \s 247 | (\d+) \s 248 | (\d+) \s 249 | (#{valid_name}) 250 | /ix 251 | add_record('srv', :name => $1, :ttl => $2, :class => $3, :pri => $4, :weight => $5, 252 | :port => $6, :host => $7) 253 | elsif line=~/^(#{valid_name})? \s* 254 | #{ttl_cls} 255 | DS \s 256 | (\d+) \s 257 | (\w+) \s 258 | (\d+) \s 259 | #{hexadeimal} 260 | /ix 261 | add_record( 'ds', :name => $1, :ttl => $2, :class => $3, :key_tag => $4.to_i, :algorithm => $5, 262 | :digest_type => $6.to_i, :digest => $7.gsub( /\s/,'') ) 263 | elsif line=~/^(#{valid_name})? \s* 264 | #{ttl_cls} 265 | NSEC \s 266 | (#{valid_name}) \s 267 | ([\s\w]*) 268 | /ix 269 | add_record( 'nsec', :name => $1, :ttl => $2, :class => $3, :next => $4, :types => $5.strip ) 270 | elsif line=~/^(#{valid_name})? \s* 271 | #{ttl_cls} 272 | NSEC3 \s 273 | (\d+) \s 274 | (\d+) \s 275 | (\d+) \s 276 | (-|[A-F0-9]*) \s 277 | ([A-Z2-7=]*) \s 278 | ([\s\w]*) 279 | /ix 280 | add_record( 'nsec3', :name => $1, :ttl => $2, :class => $3, :algorithm => $4, :flags => $5, 281 | :iterations => $6, :salt => $7, :next => $8.strip, :types => $9.strip ) 282 | elsif line=~/^(#{valid_name})? \s* 283 | #{ttl_cls} 284 | NSEC3PARAM \s 285 | (\d+) \s 286 | (\d+) \s 287 | (\d+) \s 288 | (-|[A-F0-9]*) 289 | /ix 290 | add_record( 'nsec3param', :name => $1, :ttl => $2, :class => $3, :algorithm => $4, :flags => $5, 291 | :iterations => $6, :salt => $7 ) 292 | elsif line=~/^(#{valid_name})? \s* 293 | #{ttl_cls} 294 | DNSKEY \s 295 | (\d+) \s 296 | (\d+) \s 297 | (\w+) \s 298 | #{base64} 299 | /ix 300 | add_record( 'dnskey', :name => $1, :ttl => $2, :class => $3, :flag => $4.to_i, :protocol => $5.to_i, 301 | :algorithm => $6, :public_key => $7.gsub( /\s/,'') ) 302 | elsif line=~/^(#{valid_name})? \s* 303 | #{ttl_cls} 304 | RRSIG \s 305 | (\w+) \s 306 | (\w+) \s 307 | (\d+) \s 308 | (\d+) \s 309 | (\d+) \s 310 | (\d+) \s 311 | (\d+) \s 312 | (#{valid_name}) \s 313 | #{base64} 314 | /ix 315 | add_record( 'rrsig', :name => $1, :ttl => $2, :class => $3, :type_covered => $4, :algorithm => $5, 316 | :labels => $6.to_i, :original_ttl => $7.to_i, :expiration => $8.to_i, :inception => $9.to_i, 317 | :key_tag => $10.to_i, :signer => $11, :signature => $12.gsub( /\s/,'') ) 318 | elsif line=~/^(#{valid_name}) \s* 319 | #{ttl_cls} 320 | TLSA \s 321 | (\d+) \s 322 | (\d+) \s 323 | (\d+) \s 324 | #{base64} 325 | /ix 326 | add_record( 'tlsa', :name => $1, :ttl => $2, :class => $3, :certificate_usage => $4.to_i, 327 | :selector => $5.to_i, :matching_type => $6.to_i, :data => $7 ) 328 | elsif line=~/^(#{valid_name})? \s* 329 | #{ttl_cls} 330 | NAPTR \s 331 | (\d+) \s 332 | (\d+) \s 333 | #{quoted} \s 334 | #{quoted} \s 335 | #{quoted} \s 336 | (#{valid_name}) 337 | /ix 338 | add_record( 'naptr', :name => $1, :ttl => $2, :class => $3, :order => $4.to_i, :preference => $5.to_i, 339 | :flags => $6, :service => $7, :regexp => $8, :replacement => $9 ) 340 | elsif line=~/^(#{valid_name}) \s+ 341 | #{ttl_cls} 342 | SOA \s+ 343 | (#{valid_name}) \s+ 344 | (#{valid_name}) \s* 345 | \s* 346 | (#{rr_ttl}) \s+ 347 | (#{rr_ttl}) \s+ 348 | (#{rr_ttl}) \s+ 349 | (#{rr_ttl}) \s+ 350 | (#{rr_ttl}) \s* 351 | /ix 352 | ttl = @soa[:ttl] || $2 || '' 353 | @soa[:origin] = $1 354 | @soa[:ttl] = ttl 355 | @soa[:primary] = $4 356 | @soa[:email] = $5 357 | @soa[:serial] = $6 358 | @soa[:refresh] = $7 359 | @soa[:retry] = $8 360 | @soa[:expire] = $9 361 | @soa[:minimumTTL] = $10 362 | 363 | elsif line=~ /^(#{valid_name})? \s* 364 | #{ttl_cls} 365 | PTR \s+ 366 | (#{valid_name}) 367 | /ix 368 | add_record('ptr', :name => $1, :class => $3, :ttl => $2, :host => $4) 369 | elsif line =~ /^(#{valid_name})? \s* #{ttl_cls} CAA\s+ (\d+) \s+ (#{valid_name}) \s+ (.*)$/ix 370 | add_record('caa', :name => $1, :ttl => $2, :class => $3, :flag=> $4.to_i, :tag => $5, :value => $6) 371 | elsif line =~ /^(#{valid_name})? \s* #{ttl_cls} TXT \s+ (.*)$/ix 372 | add_record('txt', :name => $1, :ttl => $2, :class => $3, :text => $4.strip) 373 | elsif line =~ /^(#{valid_name})? \s* #{ttl_cls} SPF \s+ (.*)$/ix 374 | add_record('spf', :name => $1, :ttl => $2, :class => $3, :text => $4.strip) 375 | elsif line =~ /\$TTL\s+(#{rr_ttl})/i 376 | @ttl = $1 377 | end 378 | end 379 | 380 | def parse 381 | Zonefile.simplify(@data).each_line do |line| 382 | parse_line(line) 383 | end 384 | end 385 | 386 | 387 | # Build a new nicely formatted Zonefile 388 | # 389 | def output 390 | out =<<-ENDH 391 | ; 392 | ; Database file #{@filename || 'unknown'} for #{@origin || 'unknown'} zone. 393 | ; Zone version: #{self.soa[:serial]} 394 | ; 395 | #{self.soa[:origin]} #{self.soa[:ttl]} IN SOA #{self.soa[:primary]} #{self.soa[:email]} ( 396 | #{self.soa[:serial]} ; serial number 397 | #{self.soa[:refresh]} ; refresh 398 | #{self.soa[:retry]} ; retry 399 | #{self.soa[:expire]} ; expire 400 | #{self.soa[:minimumTTL]} ; minimum TTL 401 | ) 402 | 403 | #{@origin ? "$ORIGIN #{@origin}" : ''} 404 | #{@ttl ? "$TTL #{@ttl}" : ''} 405 | ENDH 406 | out << "\n; Zone NS Records\n" unless self.ns.empty? 407 | self.ns.each do |ns| 408 | out << "#{ns[:name]} #{ns[:ttl]} #{ns[:class]} NS #{ns[:host]}\n" 409 | end 410 | out << "\n; Zone MX Records\n" unless self.mx.empty? 411 | self.mx.each do |mx| 412 | out << "#{mx[:name]} #{mx[:ttl]} #{mx[:class]} MX #{mx[:pri]} #{mx[:host]}\n" 413 | end 414 | 415 | out << "\n; Zone A Records\n" unless self.a.empty? 416 | self.a.each do |a| 417 | out << "#{a[:name]} #{a[:ttl]} #{a[:class]} A #{a[:host]}\n" 418 | end 419 | 420 | out << "\n; Zone CNAME Records\n" unless self.cname.empty? 421 | self.cname.each do |cn| 422 | out << "#{cn[:name]} #{cn[:ttl]} #{cn[:class]} CNAME #{cn[:host]}\n" 423 | end 424 | 425 | out << "\n; Zone AAAA Records\n" unless self.a4.empty? 426 | self.a4.each do |a4| 427 | out << "#{a4[:name]} #{a4[:ttl]} #{a4[:class]} AAAA #{a4[:host]}\n" 428 | end 429 | 430 | out << "\n; Zone TXT Records\n" unless self.txt.empty? 431 | self.txt.each do |tx| 432 | out << "#{tx[:name]} #{tx[:ttl]} #{tx[:class]} TXT #{tx[:text]}\n" 433 | end 434 | 435 | out << "\n; Zone SPF Records\n" unless self.spf.empty? 436 | self.spf.each do |spf| 437 | out << "#{spf[:name]} #{spf[:ttl]} #{spf[:class]} SPF #{spf[:text]}\n" 438 | end 439 | 440 | out << "\n; Zone SRV Records\n" unless self.srv.empty? 441 | self.srv.each do |srv| 442 | out << "#{srv[:name]} #{srv[:ttl]} #{srv[:class]} SRV #{srv[:pri]} #{srv[:weight]} #{srv[:port]} #{srv[:host]}\n" 443 | end 444 | 445 | out << "\n; Zone PTR Records\n" unless self.ptr.empty? 446 | self.ptr.each do |ptr| 447 | out << "#{ptr[:name]} #{ptr[:ttl]} #{ptr[:class]} PTR #{ptr[:host]}\n" 448 | end 449 | 450 | out << "\n; Zone DS Records\n" unless self.ds.empty? 451 | self.ds.each do |ds| 452 | out << "#{ds[:name]} #{ds[:ttl]} #{ds[:class]} DS #{ds[:key_tag]} #{ds[:algorithm]} #{ds[:digest_type]} #{ds[:digest]}\n" 453 | end 454 | 455 | out << "\n; Zone NSEC Records\n" unless self.ds.empty? 456 | self.nsec.each do |nsec| 457 | out << "#{nsec[:name]} #{nsec[:ttl]} #{nsec[:class]} NSEC #{nsec[:next]} #{nsec[:types]}\n" 458 | end 459 | 460 | out << "\n; Zone NSEC3 Records\n" unless self.ds.empty? 461 | self.nsec3.each do |nsec3| 462 | out << "#{nsec3[:name]} #{nsec3[:ttl]} #{nsec3[:class]} NSEC3 #{nsec3[:algorithm]} #{nsec3[:flags]} #{nsec3[:iterations]} #{nsec3[:salt]} #{nsec3[:next]} #{nsec3[:types]}\n" 463 | end 464 | 465 | out << "\n; Zone NSEC3PARAM Records\n" unless self.ds.empty? 466 | self.nsec3param.each do |nsec3param| 467 | out << "#{nsec3param[:name]} #{nsec3param[:ttl]} #{nsec3param[:class]} NSEC3PARAM #{nsec3param[:algorithm]} #{nsec3param[:flags]} #{nsec3param[:iterations]} #{nsec3param[:salt]}\n" 468 | end 469 | 470 | out << "\n; Zone DNSKEY Records\n" unless self.ds.empty? 471 | self.dnskey.each do |dnskey| 472 | out << "#{dnskey[:name]} #{dnskey[:ttl]} #{dnskey[:class]} DNSKEY #{dnskey[:flag]} #{dnskey[:protocol]} #{dnskey[:algorithm]} #{dnskey[:public_key]}\n" 473 | end 474 | 475 | out << "\n; Zone RRSIG Records\n" unless self.ds.empty? 476 | self.rrsig.each do |rrsig| 477 | out << "#{rrsig[:name]} #{rrsig[:ttl]} #{rrsig[:class]} RRSIG #{rrsig[:type_covered]} #{rrsig[:algorithm]} #{rrsig[:labels]} #{rrsig[:original_ttl]} #{rrsig[:expiration]} #{rrsig[:inception]} #{rrsig[:key_tag]} #{rrsig[:signer]} #{rrsig[:signature]}\n" 478 | end 479 | 480 | out << "\n; Zone TLSA Records\n" unless self.tlsa.empty? 481 | self.tlsa.each do |tlsa| 482 | out << "#{tlsa[:name]} #{tlsa[:ttl]} #{tlsa[:class]} TLSA #{tlsa[:certificate_usage]} #{tlsa[:selector]} #{tlsa[:matching_type]} #{tlsa[:data]}\n" 483 | end 484 | 485 | out << "\n; Zone NAPTR Records\n" unless self.ds.empty? 486 | self.naptr.each do |naptr| 487 | out << "#{naptr[:name]} #{naptr[:ttl]} #{naptr[:class]} NAPTR #{naptr[:order]} #{naptr[:preference]} #{naptr[:flags]} #{naptr[:service]} #{naptr[:regexp]} #{naptr[:replacement]}\n" 488 | end 489 | 490 | out << "\n; Zone CAA Records\n" unless self.caa.empty? 491 | self.caa.each do |caa| 492 | out << "#{caa[:name]} #{caa[:ttl]} #{caa[:class]} CAA #{caa[:flag]} #{caa[:tag]} #{caa[:value]}\n" 493 | end 494 | 495 | out 496 | end 497 | 498 | end 499 | 500 | --------------------------------------------------------------------------------