diff --git a/bin/z2monitor b/bin/z2monitor new file mode 100755 index 0000000..d6afe5e --- /dev/null +++ b/bin/z2monitor @@ -0,0 +1,40 @@ +#!/usr/bin/ruby + +require 'optparse' +require 'z2monitor' + +opts = {} +OptionParser.new do |o| + o.banner = "usage: z2monitor [options]" + o.on('--ack MATCH', '-a', "Acknowledge current events that match a pattern MATCH. No wildcards.") { |a| opts[:ack] = a.tr('^ A-Za-z0-9[]{}()|,-.', '') } + o.on('--disable-maintenance', '-m', "Filter out servers marked as being in maintenance.") { |m| opts[:maint] = 1 } + o.on('--minimum-severity PRIORITY', '-M', "Show events with a priority greater than M. Accepted values are 0 to 5. Default is 2.") { |ms| opts[:min_severity] = ms.tr('^0-5', '') } + o.on('--priority-list LIST', '-l', "Comma-delimited list of what priority events to show.") { |l| opts[:priority_list] = l.tr('^,0-5', '') } + o.on('--hide-acknowledged-alerts', '-H', "Don't show events that have already been acknowledged.") { |h| opts[:hideack] = 1 } + o.on('--print-once', '-1', "Only check Zabbix once and print out all alerts.") { |p| opts[:once] = 1 } + o.on('-h', 'Show this help') { puts '',o,''; exit } + o.parse! +end + +monitor = Zabbix::Monitor.new() +monitor.hide_maintenance = opts[:maint] unless opts[:maint].nil? +monitor.hide_acknowledged_alerts = opts[:hideack] unless opts[:hideack].nil? +monitor.min_severity = opts[:min_severity] unless opts[:min_severity].nil? and opts[:min_severity] != '' +monitor.priority_list = opts[:priority_list] unless opts[:priority_list].nil? + +if opts[:ack] + monitor.acknowledge(opts[:ack]) +elsif opts[:once] + monitor.get_dashboard('full') +else + begin + system "stty -echo" + Signal.trap("SIGINT") { abort } + while true + monitor.get_dashboard() + 0.upto(20) { sleep 0.5 } + end + ensure + system "stty echo" + end +end diff --git a/lib/z2monitor.rb b/lib/z2monitor.rb new file mode 100644 index 0000000..3d3e3b3 --- /dev/null +++ b/lib/z2monitor.rb @@ -0,0 +1,170 @@ +#!/usr/bin/ruby + +require 'colored' + +require 'z2monitor/api' +require 'z2monitor/misc' + +module Zabbix + class Monitor + attr_accessor :api, :hide_maintenance, :hide_acknowledged_alerts, :min_severity, :priority_list + + class EmptyFileError < StandardError + attr_reader :message + def initialize(reason, file) + @message = reason + puts "[INFO] Deleting #{file}" + File.delete(file) + end + end + def initialize() + @hide_maintenance = 0 + @hide_acknowledged_alerts = 0 + @min_severity = '2' + @priority_list = '' + uri = self.check_uri() + @api = Zabbix::API.new(uri) + @api.token = self.check_login + end + def check_uri() + uri_path = File.expand_path("~/.zmonitor-server") + if File.exists?(uri_path) + uri = File.open(uri_path).read() + else + puts "Where is your Zabbix located? (please include https/http - for example, https://localhost)" + uri = "#{STDIN.gets.chomp()}/api_jsonrpc.php" + f = File.new(uri_path, "w+") + f.write(uri) + f.close + end + #if uri !=~ /^https?:\/\/.*\/api_jsonrpc\.php/ + #puts "The URI we're using is invalid, sir. Resetting..." + #check_uri() + #end + #puts "Okay, using #{uri}." + raise EmptyFileError.new('URI is empty for some reason', uri_path) if uri == '' || uri.nil? + return uri + end + def check_login() + token_path = File.expand_path("~/.zmonitor-token") + if File.exists?(token_path) + token = File.open(token_path).read() + else + print "Please enter your Zabbix username: " + user = STDIN.gets.chomp() + print "Please enter your Zabbix password: " + begin + system "stty -echo" + password = gets.chomp + ensure + system "stty echo" + puts + end + token = @api.user.login(user, password).chomp + f = File.new(token_path, "w+") + f.write(token) + f.close + end + raise EmptyFileError.new("Token is empty!", token_path) if token == '' || token.nil? + return token + end + def get_events() + current_time = Time.now.to_i # to be used in getting event durations, but it really depends on the master + triggers = unacked_triggers = @api.trigger.get_active(@min_severity, @hide_maintenance, @hide_acknowledged_alerts, @priority_list) # Call the API for a list of active triggers + unacked_triggers = @api.trigger.get_active(@min_severity, @hide_maintenance, 1, @priority_list) if @hide_acknowledged_alerts == 0 # Call it again to get just those that are unacknowledged + current_events = [] + triggers.each do |t| + next if t['hosts'][0]['status'] == '1' or t['items'][0]['status'] == '1' # skip disabled items/hosts that the api call returns + current_events << { + :id => t['triggerid'].to_i, + :time => t['lastchange'].to_i, + :fuzzytime => fuzz(current_time - t['lastchange'].to_i), + :severity => t['priority'].to_i, + :hostname => t['host'], + :description => t['description'].gsub(/ (on(| server) |to |)#{t['host']}/, '')#, + } + end + current_events.each do |e| + s = unacked_triggers.select{ |t| t['triggerid'] == "#{e[:id]}" } + e[:acknowledged] = s[0] ? 0 : 1 + end + # Sort the events decreasing by severity, and then descending by duration (smaller timestamps at top) + return current_events.sort_by { |t| [ -t[:severity], t[:time] ] } + end + def get_dashboard(format = '') + max_lines = `tput lines`.to_i - 1 + cols = `tput cols`.to_i + eventlist = self.get_events() #TODO: get_events(max_lines) + pretty_output = [] + pretty_output << ["Last updated: %8s%#{cols-40}sZ2monitor Dashboard".cyan_on_blue % [Time.now.strftime('%T'),'']] if format != 'full' + if eventlist.length != 0 + max_hostlen = eventlist.each.max { |a,b| a[:hostname].length <=> b[:hostname].length }[:hostname].length + max_desclen = eventlist.each.max { |a,b| a[:description].length <=> b[:description].length }[:description].length + eventlist.each do |e| + break if pretty_output.length == max_lines and format != 'full' + ack = "N".red + ack = "Y".green if e[:acknowledged] == 1 + pretty_output << "%s " % e[:fuzzytime] + "%-#{max_hostlen}s " % e[:hostname] + + "%-#{max_desclen}s".color_by_severity(e[:severity]) % e[:description] + " %s" % ack + end + else + pretty_output << ['', + 'The API calls returned 0 results. Either your servers are very happy, or Z2Monitor is not working correctly.', + '', "Please check your dashboard at #{@api.server.to_s.gsub(/\/api_jsonrpc.php/, '')} to verify activity.", '', + 'Z2Monitor will continue to refresh every ten seconds unless you interrupt it.'] + end + print "\e[H\e[2J" if format != 'full' # clear terminal screen + puts pretty_output + end + def acknowledge(pattern = '') + puts 'Retrieving list of active unacknowledged triggers that match: '.bold.blue + '%s'.green % pattern, '' + filtered = [] + eventlist = self.get_events() + eventlist.each do |e| + if e[:hostname] =~ /#{pattern}/ or e[:description] =~ /#{pattern}/ + event = @api.event.get_last_by_trigger(e[:id]) + e[:eventid] = event['eventid'].to_i + e[:acknowledged] = event['acknowledged'].to_i + filtered << e if e[:acknowledged] == 0 + end + end + abort("No alerts found, so aborting".yellow) if filtered.length == 0 + filtered.each.with_index do |a,i| + message = '%s - %s (%s)'.color_by_severity(a[:severity]) % [ a[:fuzzytime], a[:description], a[:hostname] ] + puts "%4d >".bold % (i+1) + message + end + + puts '', ' Selection - enter "all", or a set of numbers listed above separated by spaces.' + print ' Sel > '.bold + input = STDIN.gets.chomp() + + no_ack_msg = "Not acknowledging anything." + raise StandardError.new("No input. #{no_ack_msg}".green) if input == '' + to_ack = (1..filtered.length).to_a if input =~ /^\s*all\s*$/ # only string we'll accept + raise StandardError.new("Invalid input. #{no_ack_msg}".red) if to_ack.nil? and (input =~ /^([0-9 ]+)$/).nil? + to_ack = input.split.map(&:to_i).sort if to_ack.nil? # Split our input into a sorted array of integers + # Let's first check if a value greater than possible was given, to help prevent typos acknowledging the wrong thing + to_ack.each { |i| raise StandardError.new("You entered a value greater than %d! Please double check. #{no_ack_msg}".yellow % filtered.length) if i > filtered.length } + + puts '', ' Message - enter an acknowledgement message below, or leave blank for the default.' + print ' Msg > '.bold + message = STDIN.gets.chomp() + puts + + # Finally! Acknowledge EVERYTHING + to_ack.each do |a| + puts 'Acknowledging: '.green + '%s (%s)' % [ filtered[a-1][:description], filtered[a-1][:hostname] ] + if message == '' + @api.whoami = @api.user.get_fullname() + @api.event.acknowledge(filtered[a-1][:eventid]) + else + @api.event.acknowledge(filtered[a-1][:eventid], message) + end + end + end + # Save a time offset between the local computer and the Zabbix master + def calibrate() + # + end + end +end diff --git a/lib/z2monitor/api.rb b/lib/z2monitor/api.rb new file mode 100755 index 0000000..3e5d7d9 --- /dev/null +++ b/lib/z2monitor/api.rb @@ -0,0 +1,98 @@ +#!/usr/bin/ruby + +require 'json' +require 'net/http' +require 'net/https' + +abort("Could not load API libraries. Did you install a JSON library? (json / json_pure / json-jruby)") unless Object.const_defined?(:JSON) + +# create the module/class stub so we can require the API class files properly +module Zabbix + class API + end +end + +# load up the different API classes and methods +require 'z2monitor/api/event' +require 'z2monitor/api/trigger' +require 'z2monitor/api/user' + +module Zabbix + class API + attr_accessor :server, :verbose, :token, :whoami + + attr_accessor :event, :trigger, :user # API classes + + def initialize( server = "http://localhost", verbose = false) + # Parse the URL beforehand + @server = URI.parse(server) + @verbose = verbose + + # set up API class methods + @user = Zabbix::User.new(self) + @event = Zabbix::Event.new(self) + @trigger = Zabbix::Trigger.new(self) + end + + # More specific error names, may add extra handling procedures later + class ResponseCodeError < StandardError + end + class ResponseError < StandardError + end + class NotAuthorisedError < StandardError + end + + def call_api(message) + # Finish preparing the JSON call + message['id'] = rand 65536 if message['id'].nil? + message['jsonrpc'] = '2.0' + # Check if we have authorization token if we're not logging in + if @token.nil? && message['method'] != 'user.login' + puts "[ERROR] Authorisation Token not initialised. message => #{message}" + raise NotAuthorisedError.new() + else + message['auth'] = @token if message['method'] != 'user.login' + end + + json_message = JSON.generate(message) # Create a JSON string + + # Open TCP connection to Zabbix master + connection = Net::HTTP.new(@server.host, @server.port) + connection.read_timeout = 300 + # Check to see if we're connecting via SSL + if @server.scheme == 'https' then + connection.use_ssl = true + connection.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + + # Prepare POST request for sending + request = Net::HTTP::Post.new(@server.request_uri) + request.add_field('Content-Type', 'application/json-rpc') + request.body = json_message + + # Send request + begin + puts "[INFO] Attempting to send request => #{request}, request body => #{request.body}" if @verbose + response = connection.request(request) + rescue ::SocketError => e + puts "[ERROR] Could not complete request: SocketError => #{e.message}" if @verbose + raise SocketError.new(e.message) + rescue Timeout::Error => e + puts "[ERROR] Timed out from Zabbix master. Is it being funky? => #{e.message}" + exit + end + + puts "[INFO] Received response: #{response}" if @verbose + raise ResponseCodeError.new("[ERROR] Did not receive 200 OK, but HTTP code #{response.code}") if response.code != "200" + + # Check for an error, and return the parsed result if everything's fine + parsed_response = JSON.parse(response.body) + if error = parsed_response['error'] + raise ResponseError.new("[ERROR] Received error response: code => #{error['code'].to_s}; message => #{error['message']}; data => #{error['data']}") + end + + return parsed_response['result'] + end + end +end + diff --git a/lib/z2monitor/api/event.rb b/lib/z2monitor/api/event.rb new file mode 100644 index 0000000..92a7d67 --- /dev/null +++ b/lib/z2monitor/api/event.rb @@ -0,0 +1,39 @@ +# api.event functions + +module Zabbix + class Event < API + attr_accessor :parent + def initialize(parent) + @parent = parent + @verbose = @parent.verbose + end + def call_api(message) + return @parent.call_api(message) + end + # General event.get + def get( options = {} ) + request = { 'method' => 'event.get', 'params' => options } + return call_api(request) + end + # Get the most recent event's information for a particular trigger + def get_last_by_trigger( triggerid = '' ) + request = { + 'method' => 'event.get', + 'params' => + { + 'triggerids' => [triggerid.to_s], + 'sortfield' => 'clock', + 'sortorder' => 'DESC', + 'limit' => '1', + 'output' => 'extend' + } + } + return call_api(request)[0] + end + # Mark an event acknowledged and leave a message + def acknowledge( events = [], message = "#{@parent.whoami} is working on this." ) + request = { 'method' => 'event.acknowledge', 'params' => { 'eventids' => events, 'message' => message } } + call_api(request) + end + end +end \ No newline at end of file diff --git a/lib/z2monitor/api/user.rb b/lib/z2monitor/api/user.rb new file mode 100644 index 0000000..6e014ed --- /dev/null +++ b/lib/z2monitor/api/user.rb @@ -0,0 +1,40 @@ +# api.user functions + +module Zabbix + class User < API + attr_accessor :parent + def initialize(parent) + @parent = parent + @verbose = @parent.verbose + end + def call_api(message) + return @parent.call_api(message) + end + # General user.get + def get( options = {} ) + request = { 'method' => 'user.get', 'params' => options } + return call_api(request) + end + # Get first and last name of currently logged in user + def get_fullname() + request = { 'method' => 'user.get', 'output' => 'extend' } + whoami = self.get({ 'output' => 'extend' }) + return whoami[0]["name"] + " " + whoami[0]["surname"] + end + # Perform a login procedure + def login(user, password) + request = { 'method' => 'user.login', 'params' => { 'user' => user, 'password' => password, }, 'id' => 1 } + puts "[INFO] Logging in..." if @verbose + result = call_api(request) + puts "[INFO] Successfully logged in as #{user}! result => #{result}" if @verbose + return result + end + # Perform a logout + def logout() + request = { 'method' => 'user.logout' } + puts "[INFO] Logging out..." if @verbose + call_api(request) + puts "[INFO] Successfully logged out." if @verbose + end + end +end \ No newline at end of file diff --git a/lib/z2monitor/misc.rb b/lib/z2monitor/misc.rb new file mode 100644 index 0000000..1375264 --- /dev/null +++ b/lib/z2monitor/misc.rb @@ -0,0 +1,31 @@ +#!/usr/bin/ruby + +# Miscellaneous functions that aren't technically part of Zabbix, but are used in zabbixmon + +def fuzz(t) + t = 0 if t < 0 # we don't need negative fuzzy times. + d = t / 86400 + h = t % 86400 / 3600 + m = t % 3600 / 60 + s = t % 60 + fuzzy = ['d', 'h', 'm', 's'].map do |unit| + amt = eval(unit) + "%3d#{unit}" % amt + end.join + return "#{fuzzy}"[8..-1] if h == 0 + return "#{fuzzy}"[4..-5] if d == 0 + return "#{fuzzy}"[0..-9] +end + +class String + def color_by_severity( level = 0 ) + case level + when 5; self.bold.red + when 4; self.yellow + when 3; self.green + when 2; self.cyan + when 1; self.bold.white + else self + end + end +end diff --git a/z2monitor-1.0.0.gem b/z2monitor-1.0.0.gem new file mode 100644 index 0000000..45dea86 Binary files /dev/null and b/z2monitor-1.0.0.gem differ