#!/usr/bin/env ruby # frozen_string_literal: true # ansible managed # # check_zones_in_sync # # Check if zones are in sync on multiple DNS servers really fast. # # Info and updates: https://github.com/igloonet/icinga-plugins # # v0.2 - timeouts and celluloid/io for much faster check # v0.3 - switch to native threads instead of unmaintained Celluloid/IO # Copyright (c) 2014 igloonet, s.r.o. https://igloonet.cz/ # # 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. VERSION = '0.3'.freeze require 'optparse' require 'ostruct' require 'resolv' # Option parsing class OptparseChzones def self.parse(args) options = OpenStruct.new options.servers = [] options.file = 'domains.txt' options.timing = false options.workers = 50 options.timeout = 2 options.timeout_repeat = 1 opt_parser = OptionParser.new do |opts| opts.banner = 'Usage: check_zones_in_sync -s dns,servers [-f domains.txt]' opts.separator '' opts.separator 'Specific options:' opts.on('-s', '--servers x,y,z', Array, 'DNS servers to check') do |s| options.servers = s end opts.on('-w', '--workers 100', Integer, 'Number for workers. Experiment to achieve best results but do not set too high.') do |w| options.workers = w end opts.on('-f', '--file [path_to_domain_file]', 'File with list of zones') do |f| options.file = f end opts.on('-d', '--[no-]debug', 'Turn on debugging') do |d| options.debug = d options.timing = true end opts.on('-t', '--[no-]timing', 'Turn on spent time info') do |t| options.timing = t end opts.on('-r', '--timeout-repeat N', Integer, 'How many times should we repeat query in case of timeout? Default 1.') do |r| options.timeout_repeat = r end opts.on('-v', '--version') do puts "check_zones_in_sync\n\nversion: #{VERSION}" end opts.on_tail('-h', '--help', 'Show this help') do puts opts end end opt_parser.parse!(args) options end end # Zone check class class ZoneCheck OK = 0 TIMEOUT = 1 MISSING = 2 def initialize(options) @options = options @servers = options.servers end # Ask one server for the zone's SOA serial. # Resolv::DNS handles timeout and retry natively via #timeouts=. def check_zone_server(zone, server) dns = Resolv::DNS.new(nameserver: [server]) dns.timeouts = [@options.timeout] * (@options.timeout_repeat + 1) begin soa = dns.getresource(zone, Resolv::DNS::Resource::IN::SOA) { status: OK, serial: soa.serial } rescue Resolv::ResolvTimeout { status: TIMEOUT, serial: nil } rescue Resolv::ResolvError { status: MISSING, serial: nil } ensure dns.close end end # check zone across all servers def check(zone) puts "checking zone #{zone}" if @options.debug serials = [] @servers.each do |server| res = check_zone_server(zone, server) puts "zone #{zone}, server #{server}, status: #{res[:status]}, serial: #{res[:serial]}" if @options.debug case res[:status] when OK serials << res[:serial] when MISSING return [2, "Zone #{zone} is missing on #{server}."] when TIMEOUT return [2, "Zone #{zone} timed out on #{server}."] end end if serials.uniq.size == 1 [0, "Zone #{zone} OK"] else [2, "Zone #{zone} NOT in sync."] end end end # get options, start timer options = OptparseChzones.parse(ARGV) t1 = Time.now if options.timing zones = File.readlines(options.file).map(&:chomp).reject(&:empty?) # UNKN when zero lines in file if zones.empty? puts "No lines in zone file #{options.file}" exit 3 end # Worker pool: N threads pull zones from a Queue and check them in parallel. # DNS UDP queries release the GVL during recvfrom, so native threads give us # real concurrency without an event loop. Each thread keeps its own Resolv::DNS # instances (one per query) — Resolv::DNS is not safe to share across threads. work_queue = Queue.new zones.each { |z| work_queue << z } options.workers.times { work_queue << nil } # poison pills, one per worker results_mutex = Mutex.new results = [] workers = options.workers.times.map do Thread.new do checker = ZoneCheck.new(options) loop do zone = work_queue.pop break if zone.nil? result = checker.check(zone) results_mutex.synchronize { results << result } end end end workers.each(&:join) # default message is OK exit_value = 0 message = "Servers #{options.servers.join(', ')} in sync." # go through results and modify exit value and message if not 0 results.each do |code, msg| unless code.zero? exit_value = code message = msg end end # print spent time if enabled puts "Spent #{Time.now - t1} secs" if options.timing # return OK if we reached end puts message exit exit_value