require 'uri'
require 'cgi'
require 'net/http'
require 'net/https'
require 'multi_json'
require 'rubix/log'
module Rubix
# Wraps and abstracts the process of connecting to a Zabbix API.
class Connection
include Logs
# The name of the cookie used by the Zabbix web application. Used
# when emulating a request from a browser.
COOKIE_NAME = 'zbx_sessionid'
# The content type header to send when emulating a browser.
CONTENT_TYPE = 'multipart/form-data'
# @return [URI] The URI for the Zabbix API.
attr_reader :uri
# @return [Net::HTTP] the HTTP server backing the Zabbix API.
attr_reader :server
# @return [String] the authentication token provided by the Zabbix
# API for this session.
attr_reader :auth
# @return [Fixnum] the ID of the next request that will be sent.
attr_reader :request_id
# @return [String] the username of the Zabbix account used to authenticate
attr_reader :username
# @return [String] the password of the Zabbix account used to authenticate
attr_reader :password
# @return [Rubix::Response] the last response from the Zabbix API -- useful for logging purposes
attr_reader :last_response
# Set up a connection to a Zabbix API.
#
# The +uri_or_string+ can be either a string or a URI
# object.
#
# The +username+ and +password+ provided must correspond to an
# existing Zabbix account with API access enabled.
#
# @param [URI,String] uri_or_string the address of the Zabbix API server to connect to
# @param [String] username the username of an existing Zabbix API User account with API access
# @param [String] password the password for this account
def initialize uri_or_string, username=nil, password=nil
self.uri = uri_or_string
@username = username || uri.user
@password = password || uri.password
@request_id = 0
end
# Send a request to the Zabbix API. Will return a Rubix::Response
# object.
#
# Documentation on what methods and parameters are available can
# be found in the {Zabbix API
# documentation}[http://www.zabbix.com/documentation/1.8/api]
#
# Rubix.connection.request 'host.get', 'filter' => { 'host' => 'foobar' }
#
# @param [String] method the name of the Zabbix API method
# @param [Hash,Array] params parameters for the method call
# @return [Rubix::Response]
def request method, params
authorize! unless authorized?
response = till_response do
send_api_request :jsonrpc => "2.0",
:id => request_id,
:method => method,
:params => params,
:auth => auth
end
Response.new(response)
end
# Send a request to the Zabbix web application. The request is
# designed to emulate a web browser.
#
# Any values in +data+ which are file handles will trigger a
# multipart POST request, uploading those files.
#
# @param [String] verb one of "GET" or "POST"
# @param [String] path the path to send the request to
# @param [Hash] data the data to include in the request
# @return [Net::HTTP::Response]
def web_request verb, path, data={}
authorize! unless authorized?
till_response do
send_web_request(verb, path, data)
end
end
# Has this connection already been authorized and provided with a
# authorization token from the Zabbix API?
def authorized?
!auth.nil?
end
# Force the connection to execute an authorization request and
# renew (or set) the authorization token.
def authorize!
response = Response.new(till_response { send_api_request(authorization_params) })
raise AuthenticationError.new("Could not authenticate with Zabbix API at #{uri}: #{response.error_message}") if response.error?
raise AuthenticationError.new("Malformed response from Zabbix API: #{response.body}") unless response.string?
@auth = response.result
end
# Set the URI for this connection's Zabbix API server.
#
# @param [String, URI] uri_or_string the address of the Zabbix API.
# @return [Net::HTTP]
def uri= uri_or_string
if uri_or_string.respond_to?(:host)
@uri = uri_or_string
else
string = uri_or_string =~ /^http/ ? uri_or_string : 'http://' + uri_or_string.to_s
@uri = URI.parse(string)
end
@server = Net::HTTP.new(uri.host, uri.port)
if @uri.scheme == 'https'
@server.use_ssl = true
end
return @server
end
protected
# The parameters used for constructing an authorization request
# with the Zabbix API.
#
# @return [Hash]
def authorization_params
{
:jsonrpc => "2.0",
:id => request_id,
:method => "user.login",
:params => {
:user => username,
:password => password
}
}
end
# Attempt to execute a query until a non-5xx response is returned.
#
# 5xx responses can occur because the backend PHP server providing
# the Zabbix API can sometimes be unavailable if the serer is
# restarting or something like that. We keep trying until that
# doesn't happen.
#
# During long-running connections, the Zabbix server can reap the
# existing session if some time has passed since the last request
# from this Connection. This method will also refresh the
# connection in that instance.
#
# You shouldn't have to use this method directly -- the
# Rubix::Connection#authorize! and
# Rubix::Connection#request methods already use this
# functionality.
def till_response attempt=1, max_attempts=5, &block
response = block.call
Rubix.logger.log(Logger::DEBUG, "RECV: #{response.body}") if Rubix.logger
case
when response.code.to_i >= 500 && attempt <= max_attempts
sleep 1 # FIXME make the sleep time configurable...
till_response(attempt + 1, max_attempts, &block)
when response.code.to_i >= 500
raise ConnectionError.new("Too many consecutive failed requests (#{max_attempts}) to the Zabbix API at (#{uri}).")
when response.code.to_i == 200 && authorized? && response.body =~ /-32602/ && response.body =~ /Not authorized/
authorize!
till_response(attempt, max_attempts, &block)
else
@last_response = response
end
end
# Send the POST request to the Zabbix API.
#
# @param [Hash, #to_json] raw_params the complete parameters of the request.
# @return [Net::HTTP::Response]
def send_api_request raw_params
@request_id += 1
begin
raw_response = server.request(raw_api_request(raw_params))
rescue NoMethodError, Errno::ECONNREFUSED, SocketError => e
raise RequestError.new("Could not connect to Zabbix server at #{host_with_port}")
end
raw_response
end
# Send a Web request to Zabbix.
#
# The existing authorization token will be used to emulate a
# request sent by a browser.
#
# Any values in +data+ which are file handles will trigger a
# multipart POST request, uploading those files.
#
# @param [String] verb one of "GET" or "POST"
# @param [String] path the path to send the request to
# @param [Hash] data the data to include in the request
def send_web_request verb, path, data={}
# Don't increment this for web requests?
# @request_id += 1
begin
raw_response = server.request(raw_web_request(verb, path, data))
rescue NoMethodError, Errno::ECONNREFUSED, SocketError => e
raise RequestError.new("Could not connect to the Zabbix server at #{host_with_port}")
end
end
# Generate the raw POST request to send to the Zabbix API
#
# @param [Hash, #to_json] raw_params the complete parameters of the request.
# @return [Net::HTTP::Post]
def raw_api_request raw_params
json_body = MultiJson.dump(raw_params)
Rubix.logger.log(Logger::DEBUG, "SEND: #{json_body}") if Rubix.logger
Net::HTTP::Post.new(uri.path).tap do |req|
req['Content-Type'] = 'application/json-rpc'
req['User-Agent'] = "Rubix v. #{Rubix.version}"
req.body = json_body
end
end
# Generate a raw web request to send to the Zabbix web application
# as though it came from a browser.
#
# @param [String] verb the HTTP verb, either "GET" (default) or "POST"
# @param [String] path the path on the server to send the request to
# @param [Hash] data the data for the request
def raw_web_request verb, path, data={}
case
when verb == "GET"
raw_get_request(path)
when verb == "POST" && data.values.any? { |value| value.respond_to?(:read) }
raw_multipart_post_request(path, data)
when verb == "POST"
raw_post_request(path, data)
else
raise Rubix::RequestError.new("Invalid HTTP verb: #{verb}")
end
end
# Generate an authenticated GET request emulating a browser.
#
# @param [String] path the path to send the request to.
# @return [Net::HTTP::Get]
def raw_get_request(path)
Net::HTTP::Get.new(path).tap do |req|
req['Content-Type'] = self.class::CONTENT_TYPE
req['Cookie'] = "#{self.class::COOKIE_NAME}=#{CGI::escape(auth.to_s)}"
end
end
# Generate an authenticated POST request emulating a browser. It
# is assumed that +data+ is not multipart data.
#
# @param [String] path the path to send the request to.
# @param [Hash] data the data to send
# @return [Net::HTTP::Post]
def raw_post_request(path, data={})
Net::HTTP::Post.new(path).tap do |req|
req['Content-Type'] = self.class::CONTENT_TYPE
req['Cookie'] = "#{self.class::COOKIE_NAME}=#{CGI::escape(auth.to_s)}"
req.body = formatted_post_body(data)
end
end
# Generate an authenticated POST request emulating a browser.
# Assumes data is multipart data, with some values being file
# handles.
#
# @param [String] path the path to send the request to.
# @param [Hash] data the data to send
# @return [Net::HTTP::Post::Multipart]
def raw_multipart_post_request(path, data={})
require 'net/http/post/multipart'
Net::HTTP::Post::Multipart.new(path, wrapped_multipart_post_data(data)).tap do |req|
req['Cookie'] = "#{self.class::COOKIE_NAME}=#{CGI::escape(auth.to_s)}"
end
end
# Wrap +data+ with +UploadIO+ objects so that it can be properly
# handled by the Net::HTTP::Post::Multipart class.
#
# @param [Hash] data
# @return [Hash]
def wrapped_multipart_post_data data
{}.tap do |wrapped|
data.each_pair do |key, value|
if value.respond_to?(:read)
# We are going to assume it's always XML we're uploading.
wrapped[key] = UploadIO.new(value, "application/xml", File.basename(value.path))
else
wrapped[key] = value
end
end
end
end
# Format +data+ as a POST data string.
#
# @param [Hash] data
# @return [String]
def formatted_post_body data
[].tap do |pairs|
data.each_pair do |key, value|
pairs << [key, value].map { |s| CGI::escape(s.to_s) }.join('=')
end
end.join('&')
end
# Used for generating helpful error messages.
#
# @return [String]
def host_with_port
if uri.port.nil? || uri.port.to_i == 80
uri.host
else
"#{uri.host}:#{uri.port}"
end
end
end
end