#!/usr/bin/env ruby require 'mysql' require 'readline' help = "\ synopsis : #{$0} [--user USER] [--host HOST] [--password|-pPASSWORD] [--database DB] [--help] < --interactive | zone_file > #{$0} is used to inject zone configuration for bind server into an mysql database. There is two modes : - in interactive mode, you specify zone informations on standard input. - in file mode, you pass the configuration file as argument. options : --user, -u USER : mysql user --host, -h HOST : mysql host --password : give mysql password in interactive mode -pPASSWORD : give mysql password on the command line --database, -d : name of the mysql database to use --interactive : run in interactive mode --help : print this help See the end of the source code to know what table schema is required. " args = ARGV.reverse Types = %w( SOA NS MX AAAA A CNAME PTR ) $mysql_user = ENV['USER'] $mysql_host = 'localhost' $mysql_password = '' $mysql_db = 'dns' files_to_parse = [] puts help if args.empty? until args.empty? arg = args.pop case arg when '--user', '-u' $mysql_user = args.pop when '--host', '-h' $mysql_host = args.pop when '--password' prompt_password = true when /^p(.*)/ $mysql_password = $1 when '--interactive', '-i' interactive_mode = true when '--database', '-d' $mysql_db = args.pop when '--help', '-h' puts help exit else files_to_parse << arg end end puts "invalid argument\n" << help if interactive_mode and not files_to_parse.empty? ZoneInfo = Struct.new :zone, :ttl, :type, :host, :mx_priority, :data, :primary_ns, :resp_contact, :serial, :refresh, :retry, :expire, :minimum class Zone def initialize zonename = nil @db = Mysql.new $mysql_host, $mysql_user, $mysql_password @db.select_db $mysql_db @zone_infos = [] @end = false unless zonename puts "Zone name:" zonename = Readline.readline "?> " puts "global ttl:" @gl_ttl = Readline.readline "?> " end @zonename = zonename end def interactive_zoneinfo infos = ZoneInfo.new puts "Type?" selection = Readline.readline( "?> " ).upcase Types.include?( selection ) ? ( infos.type = selection ) : ( puts "valid types are : #{Types.join ' ,'}"; return false ) puts "\nHost? (default = @)" selection = Readline.readline "?> " infos.host = ( selection == '' ? '@' : selection ) puts "\nTTL? (default = #{@gl_ttl})" selection = Readline.readline "?> " infos.ttl = ( selection == '' ? @gl_ttl : selection ) if infos.type == 'MX' puts "\nMX priority? (e.g. 10, 20, 30 ...)" infos.mx_priority = Readline.readline "?> " end unless infos.type == 'SOA' puts "\ndata" infos.data = Readline.readline "?> " else %w( primary_ns resp_contact serial refresh retry expire minimum ).each { |field| puts "\n" + field infos[ field ] = Readline.readline "?> " } end infos.zone = @zonename return infos end def parse_file file ( puts "#{file} is not a valid filename" ; exit ) unless file.class == String and File.file? file stream = File.open file ( puts "can't open file #{file}" ; return false ) if stream == nil lines = stream.readlines.reverse while line = lines.pop infos = ZoneInfo.new line.chomp! line.gsub! /;.*$/, '' line.gsub! /\t/, ' ' next if line[/^ *$/] # join splitted resource records if line[/\(/] until line[/\)/] more = lines.pop more.gsub! /;.*$/, '' more.gsub! /\t/, ' ' line = line.chomp + ' ' + more.chomp end end line.gsub! /\(/, '' line.gsub! /\)/, '' if line[/^\$TTL +([^ ]+)/] @gl_ttl = $1 next end if line[/^\$ORIGIN +([^ ]+)/] @zonename = $1 next end if line[/^\$INCLUDE/] puts "sorry, but inclusion isn't supported" exit end # parse host same = true ( same = false ; host = line[/^[^ ]+/] ) unless line[/^ /] ( puts "can't parse host" ; return false ) unless defined? host infos.host = host same ? ( line[/ +/] = '' ) : ( line[/^[^ ]+ +/] = '' ) match = line[/^[^ ]+/] # parse TTL if match[/\d+(\w?)/] infos.ttl = ( $1 ? ( correct_date_style $& ) : ( $& ) ) line[/^[^ ]+ +/] = '' match = line[/^[^ ]+/] else infos.ttl = @gl_ttl end # parse class if match[/IN|CH/] ( puts "CHAOS class is not supported" ; return false ) if $& == 'CH' line[/^[^ ]+ +/] = '' match = line[/^[^ ]+/] end # parse type match[/AAAA|A|CNAME|HINFO|MX|NS|PTR|SOA/] ( puts "can't parse type" ; p match ; p $& ; p infos ; return false ) unless $& infos.type = $& line[/^[^ ]+ +/] = '' match = line[/^[^ ]+/] # parse data if %w( AAAA A CNAME HINFO NS PTR ).include? infos.type infos.data = match # parse MX data elsif infos.type == 'MX' infos.mx_priority = match line[/^[^ ]+ +/] = '' match = line[/^[^ ]+/] infos.data = match # parse SOA data elsif infos.type == 'SOA' %w( primary_ns resp_contact serial refresh retry expire minimum ).each { |parameter| infos[parameter] = match line[/^[^ ]+ +/] = '' match = line[/^[^ ]+/] } end infos.zone = @zonename ( puts "bad checks for zone #{infos.zone}, type #{infos.type}" ; p infos ; exit ) unless check infos end complete end def correct_date_style datestring datestring[/(\d+)(m|h|d|w)/i] return false unless $1 and $2 conv = { "m" => 60, "h" => 3600, "d" => 86400, "w" => 604800 } seconds = $1.to_i * conv[ $2.downcase ].to_i return seconds.to_s end def check infos return false unless infos.class == ZoneInfo return false unless infos.zone and infos.type and infos.ttl and infos.host return false if infos.type == 'MX' and ( not infos.mx_priority or infos.mx_priority[/[^0-9]/] ) return false if infos.mx_priority and infos.type != 'MX' if infos.ttl[/[^0-9]/] infos.ttl = correct_date_style infos.ttl return false unless infos.ttl end if infos.type == 'SOA' %w( refresh retry expire minimum ).each { |field| if infos[ field ][/[^0-9]/] infos[ field ] = correct_date_style infos[ field ] return false unless infos[ field ] end } return false if infos.data else %w( primary_ns resp_contact serial refresh retry expire minimum ).each { |field| return false if infos[ field ] } return false unless infos.data end @zone_infos << infos return true end def complete @end = true @zone_infos.each { |infos| query = "insert into records( #{infos.members.join ", "} ) values ( #{infos.entries.collect {|e| ( e == nil ? ( "NULL" ) : ( "'"+ Mysql.escape_string(e.to_s) + "'" ) ) }.join ", "} )" begin res = @db.query query rescue puts "error while loading the #{infos.zone} zone" return false end } puts "zone successfuly loaded" self.freeze return true end end def continue? answer = Readline.readline( "?> " ).upcase while answer != 'Y' and answer != 'N' puts "please answer by 'y' or 'n'" answer = Readline.readline( "?> " ).upcase end return ( answer == 'Y' ? true : false ) end if prompt_password puts "Password : " $mysql_password = Readline.readline "?> " end if interactive_mode continue_app = true while continue_app zone = Zone.new continue_zone = true while continue_zone infos = zone.interactive_zoneinfo (sanity = zone.check infos ) if infos (puts "Bad zone infos, dropped"; p infos) unless sanity puts "\nIs there others infos to enter for this zone? " continue_zone = continue? end zone.complete if sanity puts "\nDo you want to create an other zone? " continue_app = continue? end else files_to_parse.each { |file| # support db.name, name.zone, name.zones, name.host and name.hosts filename format # add gsub rules if you need others zonename = File.basename( file ).gsub( /^db\./, '').gsub( /\.(hosts?|zones?)$/, '') zone = Zone.new zonename result = zone.parse_file file puts ( result ? "succeded" : "failed" ) } end # Here is how your mysql bind table should look like : # CREATE TABLE `records` ( # `id` int(10) unsigned NOT NULL auto_increment, # `zone` varchar(255) NOT NULL, # `ttl` int(11) NOT NULL default '86400', # `type` varchar(255) NOT NULL, # `host` varchar(255) NOT NULL default '@', # `mx_priority` int(11) default NULL, # `data` text, # `primary_ns` varchar(255) default NULL, # `resp_contact` varchar(255) default NULL, # `serial` bigint(20) default NULL, # `refresh` int(11) default NULL, # `retry` int(11) default NULL, # `expire` int(11) default NULL, # `minimum` int(11) default NULL, # PRIMARY KEY (`id`), # KEY `type` (`type`), # KEY `host` (`host`), # KEY `zone` (`zone`) # );