# The MIT License (MIT) # # Copyright (c) 2015 Martin Boese # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. # # = Ruby Zonefile - Parse and manipulate DNS Zone Files. # # == Description # 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 returns the # hash since there can only be one SOA information. # # The following hash keys are returned per record type: # # * SOA # - :ttl, :primary, :email, :serial, :refresh, :retry, :expire, :minimumTTL # * A # - :name, :ttl, :class, :host # * MX # - :name, :ttl, :class, :pri, :host # * NS # - :name, :ttl, :class, :host # * CNAME # - :name, :ttl, :class, :host # * TXT # - :name, :ttl, :class, :text # * A4 (AAAA) # - :name, :ttl, :class, :host # * PTR # - :name, :ttl, :class, :host # * SRV # - :name, :ttl, :class, :pri, :weight, :port, :host # * DS # - :name, :ttl, :class, :key_tag, :algorithm, :digest_type, :digest # * DNSKEY # - :name, :ttl, :class, :flag, :protocol, :algorithm, :public_key # * RRSIG # - :name, :ttl, :class, :type_covered, :algorithm, :labels, :original_ttl, # :expiration, :inception, :key_tag, :signer, :signature # * NSEC # - :name, :ttl, :class, :next, :types # * NSEC3 # - :name, :ttl, :class, :algorithm, :flags, :iterations, :salt, :next, :types # * NSEC3PARAM # - :name, :ttl, :class, :algorithm, :flags, :iterations, :salt # * TLSA # - :name, :ttl, :class, :certificate_usage, :selector, :matching_type, :data # * NAPTR # - :name, :ttl, :class, :order, :preference, :flags, :service, :regexp, :replacement # * SPF # - :name, :ttl, :class, :text # * CAA # - :name, :ttl, :class, :flag, :tag, :value # # == Examples # # === Read a Zonefile # # zf = Zonefile.from_file('/path/to/zonefile.db') # # # Display MX-Records # zf.mx.each do |mx_record| # puts "Mail Exchagne with priority: #{mx_record[:pri]} --> #{mx_record[:host]}" # end # # # Show SOA TTL # puts "Record Time To Live: #{zf.soa[:ttl]}" # # # Show A-Records # zf.a.each do |a_record| # puts "#{a_record[:name]} --> #{a_record[:host]}" # end # # # ==== Manipulate a Zonefile # # zf = Zonefile.from_file('/path/to/zonefile.db') # # # Change TTL and add an A-Record # # zf.soa[:ttl] = '123123' # Change the SOA ttl # zf.a << { :class => 'IN', :name => 'www', :host => '192.168.100.1', :ttl => 3600 } # add A-Record # # # Setting PTR records (deleting existing ones) # # zf.ptr = [ { :class => 'IN', :name=>'1.100.168.192.in-addr.arpa', :host => 'my.host.com' }, # { :class => 'IN', :name=>'2.100.168.192.in-addr.arpa', :host => 'me.host.com' } ] # # # Increase Serial Number # zf.new_serial # # # Print new zonefile # puts "New Zonefile: \n#{zf.output}" # # == Name attribute magic # # Since 1.04 the :name attribute is preserved and returned as defined in a previous record if a zonefile entry # omits it. This should be the expected behavior for most users. # You can switch this off globally by calling Zonefile.preserve_name(false) # # == Authors # # Martin Boese, based on Simon Flack Perl library DNS::ZoneParse # # Andy Newton, patch to support various additional records # class Zonefile RECORDS = %w{ mx a a4 ns cname txt ptr srv soa ds dnskey rrsig nsec nsec3 nsec3param tlsa naptr spf caa } attr :records attr :soa attr :data # global $ORIGIN option attr :origin # global $TTL option attr :ttl @@preserve_name = true # For compatibility: This can switches off copying of the :name from the # previous record in a zonefile if found omitted. # This was zonefile's behavior in <= 1.03 . def self.preserve_name(do_preserve_name) @@preserve_name = do_preserve_name end def method_missing(m, *args) mname = m.to_s.sub("=","") return super unless RECORDS.include?(mname) if m.to_s[-1].chr == '=' then @records[mname.intern] = args.first @records[mname.intern] else @records[m] end end # Compact a zonefile content - removes empty lines, comments, # converts tabs into spaces etc... def self.simplify(zf) # concatenate everything split over multiple lines in parentheses - remove ;-comments in block zf = zf.gsub(/(\([^\)]*?\))/) { |m| m.split(/\n/).map { |l| l.gsub(/\;.*$/, '') }.join("\n").gsub(/[\r\n]/, '').gsub( /[\(\)]/, '') } zf.split(/\n/).map do |line| r = line.gsub(/\t/, ' ') r = r.gsub(/\s+/, ' ') # FIXME: this is ugly and not accurate, couldn't find proper regex: # Don't strip ';' if it's quoted. Happens a lot in TXT records. (0..(r.length - 1)).find_all { |i| r[i].chr == ';' }.each do |comment_idx| if !r[(comment_idx+1)..-1].index(/['"]/) then r = r[0..(comment_idx-1)] break end end r end.delete_if { |line| line.empty? || line[0].chr == ';'}.join("\n") end # create a new zonefile object by passing the content of the zonefile def initialize(zonefile = '', file_name= nil, origin= nil) @data = zonefile @filename = file_name @origin = origin || (file_name ? file_name.split('/').last : '') @records = {} @soa = {} RECORDS.each { |r| @records[r.intern] = [] } parse end # True if no records (except sao) is defined in this file def empty? RECORDS.each do |r| return false unless @records[r.intern].empty? end true end # Create a new object by reading the content of a file def self.from_file(file_name, origin = nil) Zonefile.new(File.read(file_name), file_name.split('/').last, origin) end def add_record(type, data= {}) if @@preserve_name then @lastname = data[:name] if data[:name].to_s != '' data[:name] = @lastname if data[:name].to_s == '' end @records[type.downcase.intern] << data end # Generates a new serial number in the format of YYYYMMDDII if possible def new_serial base = "%04d%02d%02d" % [Time.now.year, Time.now.month, Time.now.day ] if ((@soa[:serial].to_i / 100) > base.to_i) then ns = @soa[:serial].to_i + 1 @soa[:serial] = ns.to_s return ns.to_s end ii = 0 while (("#{base}%02d" % ii).to_i <= @soa[:serial].to_i) do ii += 1 end @soa[:serial] = "#{base}%02d" % ii end def parse_line(line) valid_name = /[\@a-z_\-\.0-9\*]+/i valid_ip6 = /[\@a-z_\-\.0-9\*:]+/i rr_class = /\b(?:IN|HS|CH)\b/i rr_type = /\b(?:NS|A|CNAME)\b/i rr_ttl = /(?:\d+[wdhms]?)+/i ttl_cls = Regexp.new("(?:(#{rr_ttl})\s)?(?:(#{rr_class})\s)?") base64 = /([\s\w\+\/]*=*)/i hexadeimal = /([\sA-F0-9]*)/i quoted = /(\"[^\"]*\")/i data = {} if line =~ /^\$ORIGIN\s*(#{valid_name})/ix then @origin = $1 elsif line =~ /^(#{valid_name})? \s* #{ttl_cls} (#{rr_type}) \s (#{valid_name}) /ix then (name, ttl, dclass, type, host) = [$1, $2, $3, $4, $5] add_record($4, :name => $1, :ttl => $2, :class => $3, :host => $5) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} AAAA \s (#{valid_ip6}) /x then add_record('a4', :name => $1, :ttl => $2, :class => $3, :host => $4) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} MX \s (\d+) \s (#{valid_name}) /ix then add_record('mx', :name => $1, :ttl => $2, :class => $3, :pri => $4.to_i, :host => $5) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} SRV \s (\d+) \s (\d+) \s (\d+) \s (#{valid_name}) /ix add_record('srv', :name => $1, :ttl => $2, :class => $3, :pri => $4, :weight => $5, :port => $6, :host => $7) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} DS \s (\d+) \s (\w+) \s (\d+) \s #{hexadeimal} /ix add_record( 'ds', :name => $1, :ttl => $2, :class => $3, :key_tag => $4.to_i, :algorithm => $5, :digest_type => $6.to_i, :digest => $7.gsub( /\s/,'') ) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} NSEC \s (#{valid_name}) \s ([\s\w]*) /ix add_record( 'nsec', :name => $1, :ttl => $2, :class => $3, :next => $4, :types => $5.strip ) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} NSEC3 \s (\d+) \s (\d+) \s (\d+) \s (-|[A-F0-9]*) \s ([A-Z2-7=]*) \s ([\s\w]*) /ix add_record( 'nsec3', :name => $1, :ttl => $2, :class => $3, :algorithm => $4, :flags => $5, :iterations => $6, :salt => $7, :next => $8.strip, :types => $9.strip ) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} NSEC3PARAM \s (\d+) \s (\d+) \s (\d+) \s (-|[A-F0-9]*) /ix add_record( 'nsec3param', :name => $1, :ttl => $2, :class => $3, :algorithm => $4, :flags => $5, :iterations => $6, :salt => $7 ) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} DNSKEY \s (\d+) \s (\d+) \s (\w+) \s #{base64} /ix add_record( 'dnskey', :name => $1, :ttl => $2, :class => $3, :flag => $4.to_i, :protocol => $5.to_i, :algorithm => $6, :public_key => $7.gsub( /\s/,'') ) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} RRSIG \s (\w+) \s (\w+) \s (\d+) \s (\d+) \s (\d+) \s (\d+) \s (\d+) \s (#{valid_name}) \s #{base64} /ix add_record( 'rrsig', :name => $1, :ttl => $2, :class => $3, :type_covered => $4, :algorithm => $5, :labels => $6.to_i, :original_ttl => $7.to_i, :expiration => $8.to_i, :inception => $9.to_i, :key_tag => $10.to_i, :signer => $11, :signature => $12.gsub( /\s/,'') ) elsif line=~/^(#{valid_name}) \s* #{ttl_cls} TLSA \s (\d+) \s (\d+) \s (\d+) \s #{base64} /ix add_record( 'tlsa', :name => $1, :ttl => $2, :class => $3, :certificate_usage => $4.to_i, :selector => $5.to_i, :matching_type => $6.to_i, :data => $7 ) elsif line=~/^(#{valid_name})? \s* #{ttl_cls} NAPTR \s (\d+) \s (\d+) \s #{quoted} \s #{quoted} \s #{quoted} \s (#{valid_name}) /ix add_record( 'naptr', :name => $1, :ttl => $2, :class => $3, :order => $4.to_i, :preference => $5.to_i, :flags => $6, :service => $7, :regexp => $8, :replacement => $9 ) elsif line=~/^(#{valid_name}) \s+ #{ttl_cls} SOA \s+ (#{valid_name}) \s+ (#{valid_name}) \s* \s* (#{rr_ttl}) \s+ (#{rr_ttl}) \s+ (#{rr_ttl}) \s+ (#{rr_ttl}) \s+ (#{rr_ttl}) \s* /ix ttl = @soa[:ttl] || $2 || '' @soa[:origin] = $1 @soa[:ttl] = ttl @soa[:primary] = $4 @soa[:email] = $5 @soa[:serial] = $6 @soa[:refresh] = $7 @soa[:retry] = $8 @soa[:expire] = $9 @soa[:minimumTTL] = $10 elsif line=~ /^(#{valid_name})? \s* #{ttl_cls} PTR \s+ (#{valid_name}) /ix add_record('ptr', :name => $1, :class => $3, :ttl => $2, :host => $4) elsif line =~ /^(#{valid_name})? \s* #{ttl_cls} CAA\s+ (\d+) \s+ (#{valid_name}) \s+ (.*)$/ix add_record('caa', :name => $1, :ttl => $2, :class => $3, :flag=> $4.to_i, :tag => $5, :value => $6) elsif line =~ /^(#{valid_name})? \s* #{ttl_cls} TXT \s+ (.*)$/ix add_record('txt', :name => $1, :ttl => $2, :class => $3, :text => $4.strip) elsif line =~ /^(#{valid_name})? \s* #{ttl_cls} SPF \s+ (.*)$/ix add_record('spf', :name => $1, :ttl => $2, :class => $3, :text => $4.strip) elsif line =~ /\$TTL\s+(#{rr_ttl})/i @ttl = $1 end end def parse Zonefile.simplify(@data).each_line do |line| parse_line(line) end end # Build a new nicely formatted Zonefile # def output out =<<-ENDH ; ; Database file #{@filename || 'unknown'} for #{@origin || 'unknown'} zone. ; Zone version: #{self.soa[:serial]} ; #{self.soa[:origin]} #{self.soa[:ttl]} IN SOA #{self.soa[:primary]} #{self.soa[:email]} ( #{self.soa[:serial]} ; serial number #{self.soa[:refresh]} ; refresh #{self.soa[:retry]} ; retry #{self.soa[:expire]} ; expire #{self.soa[:minimumTTL]} ; minimum TTL ) #{@origin ? "$ORIGIN #{@origin}" : ''} #{@ttl ? "$TTL #{@ttl}" : ''} ENDH out << "\n; Zone NS Records\n" unless self.ns.empty? self.ns.each do |ns| out << "#{ns[:name]} #{ns[:ttl]} #{ns[:class]} NS #{ns[:host]}\n" end out << "\n; Zone MX Records\n" unless self.mx.empty? self.mx.each do |mx| out << "#{mx[:name]} #{mx[:ttl]} #{mx[:class]} MX #{mx[:pri]} #{mx[:host]}\n" end out << "\n; Zone A Records\n" unless self.a.empty? self.a.each do |a| out << "#{a[:name]} #{a[:ttl]} #{a[:class]} A #{a[:host]}\n" end out << "\n; Zone CNAME Records\n" unless self.cname.empty? self.cname.each do |cn| out << "#{cn[:name]} #{cn[:ttl]} #{cn[:class]} CNAME #{cn[:host]}\n" end out << "\n; Zone AAAA Records\n" unless self.a4.empty? self.a4.each do |a4| out << "#{a4[:name]} #{a4[:ttl]} #{a4[:class]} AAAA #{a4[:host]}\n" end out << "\n; Zone TXT Records\n" unless self.txt.empty? self.txt.each do |tx| out << "#{tx[:name]} #{tx[:ttl]} #{tx[:class]} TXT #{tx[:text]}\n" end out << "\n; Zone SPF Records\n" unless self.spf.empty? self.spf.each do |spf| out << "#{spf[:name]} #{spf[:ttl]} #{spf[:class]} SPF #{spf[:text]}\n" end out << "\n; Zone SRV Records\n" unless self.srv.empty? self.srv.each do |srv| out << "#{srv[:name]} #{srv[:ttl]} #{srv[:class]} SRV #{srv[:pri]} #{srv[:weight]} #{srv[:port]} #{srv[:host]}\n" end out << "\n; Zone PTR Records\n" unless self.ptr.empty? self.ptr.each do |ptr| out << "#{ptr[:name]} #{ptr[:ttl]} #{ptr[:class]} PTR #{ptr[:host]}\n" end out << "\n; Zone DS Records\n" unless self.ds.empty? self.ds.each do |ds| out << "#{ds[:name]} #{ds[:ttl]} #{ds[:class]} DS #{ds[:key_tag]} #{ds[:algorithm]} #{ds[:digest_type]} #{ds[:digest]}\n" end out << "\n; Zone NSEC Records\n" unless self.ds.empty? self.nsec.each do |nsec| out << "#{nsec[:name]} #{nsec[:ttl]} #{nsec[:class]} NSEC #{nsec[:next]} #{nsec[:types]}\n" end out << "\n; Zone NSEC3 Records\n" unless self.ds.empty? self.nsec3.each do |nsec3| out << "#{nsec3[:name]} #{nsec3[:ttl]} #{nsec3[:class]} NSEC3 #{nsec3[:algorithm]} #{nsec3[:flags]} #{nsec3[:iterations]} #{nsec3[:salt]} #{nsec3[:next]} #{nsec3[:types]}\n" end out << "\n; Zone NSEC3PARAM Records\n" unless self.ds.empty? self.nsec3param.each do |nsec3param| out << "#{nsec3param[:name]} #{nsec3param[:ttl]} #{nsec3param[:class]} NSEC3PARAM #{nsec3param[:algorithm]} #{nsec3param[:flags]} #{nsec3param[:iterations]} #{nsec3param[:salt]}\n" end out << "\n; Zone DNSKEY Records\n" unless self.ds.empty? self.dnskey.each do |dnskey| out << "#{dnskey[:name]} #{dnskey[:ttl]} #{dnskey[:class]} DNSKEY #{dnskey[:flag]} #{dnskey[:protocol]} #{dnskey[:algorithm]} #{dnskey[:public_key]}\n" end out << "\n; Zone RRSIG Records\n" unless self.ds.empty? self.rrsig.each do |rrsig| 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" end out << "\n; Zone TLSA Records\n" unless self.tlsa.empty? self.tlsa.each do |tlsa| out << "#{tlsa[:name]} #{tlsa[:ttl]} #{tlsa[:class]} TLSA #{tlsa[:certificate_usage]} #{tlsa[:selector]} #{tlsa[:matching_type]} #{tlsa[:data]}\n" end out << "\n; Zone NAPTR Records\n" unless self.ds.empty? self.naptr.each do |naptr| out << "#{naptr[:name]} #{naptr[:ttl]} #{naptr[:class]} NAPTR #{naptr[:order]} #{naptr[:preference]} #{naptr[:flags]} #{naptr[:service]} #{naptr[:regexp]} #{naptr[:replacement]}\n" end out << "\n; Zone CAA Records\n" unless self.caa.empty? self.caa.each do |caa| out << "#{caa[:name]} #{caa[:ttl]} #{caa[:class]} CAA #{caa[:flag]} #{caa[:tag]} #{caa[:value]}\n" end out end end