module Zabbix require 'socket' require 'json' require 'open3' ## # AgentConfiguration holds data that's scraped from a zabbix_agentd config file. It's # initialized when the gem is required. You may optionally re-initialize the # class with your own list of paths to search. If it finds configuration you can # access it with class methods. This is not meant to be instantiated. class AgentConfiguration ## # You may optionally pass an array of full paths to agent conf files to look for # during initialization. By default some common places are checked, but # you can specify your own. If you call this you'll re-initialize the class, which # will scan for values in any of the listed files it happens to find. def self.initialize(paths: [ '/etc/zabbix/zabbix_agentd.conf', '/usr/local/etc/zabbix/zabbix_agentd.conf', '/opt/zabbix/etc/zabbix_agentd.conf', '/opt/zabbix/conf/zabbix_agentd.conf' ]) @agentConfPaths = paths @proxy = nil @hostname = nil @agentConfPaths.each { |path| if File.exist?(path) File.new(path).each_line { |line| if not @proxy match = /^Server=?(.*)/.match(line) if match @proxy = match[1].strip.split(',').pop end end if not @hostname match = /^Hostname=?(.*)/.match(line) if match @hostname = match[1].strip end end break if @proxy and @hostname } end break if @proxy and @host } if not @host @host = Socket.gethostname end end ## # Returns the value of the Server= assignment in zabbix_agentd.conf (if any) # def self.zabbixProxy @proxy end ## # Returns the value of the Hostname= asignment in zabbix_agentd.conf (if any) # def self.zabbixHostname @hostname end end module Sender require 'set' ## # Connection is an abstract class that defines the basis of specific # connection types (Pipe, Socket). It is not meant to be instantiated # on its own. class Connection ## # target host (server or proxy) name or ip attr_reader :targetHost attr_reader :pipe ## # Initialize a new Connector object. Proxy is optional. # # # An attempt is made to provide sane default values. If you have a zabbix_agentd.conf # file in one of the usual places and zabbix_sender is on your path, it'll probably # just work # def initialize(proxy: Zabbix::AgentConfiguration.zabbixProxy) @targetHost = proxy @pipe = nil end ## # Aborts execution if directly called. Subclasses must override. # def open abort("Call to abstract method Connection::open") end ## # Aborts execution if directly called. Subclasses must override. # def sendBatch(aBatch) abort("Call to abstract method Connection::sendBatch(aBatch)") end ## # Send a Batch instance to zabbix (via zabbix_sender). This opens the pipe, # writes the data to the pipe, and closes the pipe all in one go. # def sendBatchAtomic(aBatch) self.open self.sendBatch(aBatch) return self.flush end ## # Closes the zabbix_sender pipe if it's open # def flush if @pipe and not @pipe.closed? @pipe.close end end end ## # Socket instances enable TCPSocket based communication with a zabbix trapper server instance # class Socket < Connection attr_reader :port ## # Create a new socket connection object. Both proxy and port are optional. # # An attempt is made to provide sane default values. If you have a zabbix_agentd.conf # file in one of the usual places and zabbix_sender is on your path, it'll probably # just work # def initialize(proxy: Zabbix::AgentConfiguration.zabbixProxy, port: 10051) super(proxy: proxy) @port = port @lastres = nil end ## # Open tcp socket to target proxy/server # def open @pipe = TCPSocket.new(@targetHost, @port) end ## # Send aBatch to zabbix via the socket. Note that zabbix will close # the connection after you send a complete message, so you # *can't* do this: # # socket.open # socket.sendBatch(a) # socket.sendBatch(b) <-- this will blow up # socket.flush # # ...as you can with Zabbix::Sender::Pipe. You're really best off # just using sendBatchAtomic for sockets. # # This assumes that the socket is already open. # def sendBatch(aBatch) header = %Q(ZBXD\x01) data = aBatch.to_senderstruct.to_json blob = %Q(#{header}#{[data.bytesize].pack("Q").force_encoding("UTF-8")}#{data}) @pipe.write(blob) respHeader = @pipe.read(header.bytesize + 8) datalen = respHeader[header.bytesize, 8].unpack("Q")[0] @lastres = JSON.parse(@pipe.read(datalen)) end def flush super return @lastres end end ## # Pipe instances utilize communication to a running instance of zabbix_sender # via a pipe to STDIN. If you want your program to send data itself (as opposed # to say printing stdin lines that you can then pipe to zabbix_sender yourself), # you'll want to make an instance of this # class Pipe < Connection ## # Create a new Pipe object. Both proxy: and path: are optional. # # An attempt is made to provide sane default values. If you have a zabbix_agentd.conf # file in one of the usual places and zabbix_sender is on your path, it'll probably # just work # def initialize(proxy: Zabbix::AgentConfiguration.zabbixProxy, path: 'zabbix_sender') super(proxy: proxy) @wait_thr = @stdout = @stderr = nil @exePath = path end ## # Opens a pipe to the zabbix_sender command with appropriate options specified. # If the pipe is already opened when this command is called, it is first closed # via a call to flush # def open self.flush #@pipe = IO.popen(%Q(#{@exePath} -z #{@targetHost} -vv -T -i-),'w') cmd = %Q(#{@exePath} -z #{@targetHost} -vv -T -i-) @pipe,@stdout,@stderr,@wait_thr = Open3.popen3(cmd) end ## # Closes the open3 pipe stuff. We need this override method as # closing an open3 pipe requires some extra work. def flush if @pipe and not @pipe.closed? @pipe.close stdout = @stdout.read stderr = @stderr.read @stdout.close @stderr.close return {stdout: stdout, stderr: stderr, success: @wait_thr.value.success?} end end ## # Send a Batch instance to zabbix (via zabbix_sender). This assumes that # the pipe is already open. def sendBatch(aBatch) # Assumes that the pipe is open @pipe.puts(aBatch.to_senderline) end end ## # ItemData instances hold the k-v pair of a value and its timestamp # along with the hostname to which the data belongs. It handles # formatting that data appropriately as input to zabbix_sender class ItemData ## # The item key that'll get the new value attr_accessor:key ## # The value that the item will get attr_accessor :value ## # The name of the zabbix host that owns the item key attr_accessor :hostname ## # The timestamp for this datapoint attr_accessor :timestamp ## # All values must be provided. def initialize(key: nil,value: nil, timestamp: nil, hostname: nil) @key = key @value = value @timestamp = timestamp @hostname = hostname end ## # Render the ItemData instance as a line of text that can be piped into zabbix_sender def to_senderline if @timestamp.to_i == 0 puts %Q("#{@hostname}" #{@key} #{@timestamp.to_i} #{@value}\n) abort("Attempt was made to render a timestamp of zero. You DO NOT want this - it can kill db performance. Fix it.") end return %Q("#{@hostname}" #{@key} #{@timestamp.to_i} #{@value}\n) end ## # Render the ItemData instance as an object suitable for conversion to json, for socket transmission def to_senderstruct if @timestamp.to_i == 0 puts %Q("#{@hostname}" #{@key} #{@timestamp.to_i} #{@value}\n) abort("Attempt was made to render a timestamp of zero. You DO NOT want this - it can kill db performance. Fix it.") else return item = { host: @hostname, key: @key, value: @value, clock: @timestamp.to_i } end end end ## # Discovery instances are a special type of ItemData that you will typically # create and hang on to as you accumulate (discover) related entities. You # then pass the discover instance into a Batch via addDiscovery(), which # includes it in the batch of data just like an ordinary ItemData instance. class Discovery < ItemData attr_reader :entities ## # The only required parameter is key:, which is the discovery rule key. # def initialize(key: nil,value: nil, timestamp: nil, hostname: nil) super @entities = Set.new end ## # This is how you pass data to zabbix that you use to construct items from item templates. Pass in # as many key-value pairs as you need. You'll reference these in the item prototype like {#MYKEY} # # Note that the keys (which you can pass as symbols if you want) are forced to uppercase. This is # here because the author once spent way too much time trying to figure out why discovery wasn't # working right one day. All caps seems to fix the issue. # def add_entity(aHash) # just send in key value pairs - these will be the variables you can use in the discovery item prototypes zabbified = Hash.new aHash.each_pair { |key,value| zabbified[%Q({##{key.to_s.upcase}})] = value } @entities.add(zabbified) end ## # Render this discovery as the structure an external discovery script should return. You can use this # if you're writing custom external discovery logic # def to_discodata disco = { 'data'=>Array.new } disco['data'] = @entities.to_a return disco end ## # Render this discovery instance as a zabbix_sender line. # def to_senderline @value = self.to_discodata.to_json super end ## # Render this discovery instance as an object suitable for conversion to json for socket transmission def to_senderstruct @value = self.to_discodata super end end ## # Batch instances hold all the data and discovery that you collect as your program # does its thing with source data. Once you've done all your data collection, you can: # # * Send the batch instance to an instance of Pipe to have it transmitted to zabbix # * puts mybatch.to_senderline to output the zabbix_sender input text # * Use it to help feed data into zabbix by whatever other mechanism you might be # using, e.g. a ruby implementation of the zabbix sender protocol # class Batch ## # This is an array of all the ItemData and Discovery instances that have been added # to this discovery since instantiation. # attr_reader :data ## # Both parameters are optional - reasonable defaults are provided. # # Bear in mind that the hostname and timestamp values you provide here will be applied # to all the ItemData and Discovery objects you add via the addItemData() and # addDiscovery() methods by default (unless you override them when you add them) def initialize(timestamp: Time.now, hostname: Zabbix::AgentConfiguration.zabbixHostname) @time = timestamp @hostname = hostname @data = Array.new end ## # Create a new instance of ItemData and add that instance to the list of data that this # batch contains. You must provide a key and a value. You *can* provide a timestamp and # a hostname. If you do not provide a timestamp or hostname, they will be given the # timestamp and hostname associated with the instance of Batch that you're working with # def addItemData(key: nil,value: nil,timestamp: @time, hostname: @hostname) @data.push(ItemData.new(key: key,value: value,timestamp: timestamp,hostname: hostname)) end ## # Add a discovery object to this batch of data. The object will be added to the # top of the item list. # # If you did not specifically provide a hostname or a timestamp when you # instantiated the Discovery, they'll given the ones provided when this instance # of Batch was constructed. # def addDiscovery(aDiscovery) # It doesn't matter right now really as zabbix has to digest the disco # and won't do it before it tries to process the data, but it makes logical # sense to put discos first. aDiscovery.timestamp = @time if not aDiscovery.timestamp aDiscovery.hostname = @hostname if not aDiscovery.hostname @data.unshift(aDiscovery) end ## # Append another batch's data into this one. def appendBatch(aBatch) @data.append(*aBatch.data) end ## # Render this batch of data as a sequence of lines of text appropriate # for sending into zabbix_sender # def to_senderline @data.collect {|line| line.to_senderline}.join end ## # Render this batch as a json object # def to_senderstruct return batch = { request: "sender data", data: @data.collect {|item| item.to_senderstruct}, clock: @time.to_i } end end end # module Sender end # module Zabbix