Skip to content

Commit cd649d6

Browse files
Refactor and enhance Zabbix API interaction. (#1111)
* Handle Zabbix API exceptions and improve error reporting. Add explicit handling for ZabbixAPIException by re-raising it when necessary. Additionally, improve error handling throughout by capturing exceptions, processing them with the `error()` method, and returning a well-structured Result object to ensure robustness and better debugging. * Add support for bearer token authentication in Zabbix server This update introduces a check for the authentication type, adding support for bearer token authentication alongside basic authentication. An exception is raised for invalid authentication methods, ensuring better error handling and robustness. * Ensure Zabbix API URL is correctly formatted. Added a conditional check to append '/api_jsonrpc.php' to the server URL only if it's not already included. This ensures compatibility with servers using preformatted URLs and avoids redundant suffixes. * Add bearer token authentication support to Zabbix API Refactors `login`, `test_login`, and `logged_in` methods to handle bearer tokens as an alternative authentication method. This improves flexibility and extends the API to support token-based authentication in addition to traditional user/password schemes. Also includes minor fixes like correcting a typo in exceptions. * Refactor Zabbix API request handling for better clarity Enhanced error handling, modularized header preparation and URL opener creation, and improved method documentation. This ensures more robust and maintainable interactions with the Zabbix API. * Add authentication property initialization in Zabbix server This change introduces the initialization of the `authentication` property using the server configuration. It ensures the Zabbix server object has the necessary authentication data available for further operations. * Add new event actions and error handling for Zabbix API Expanded event actions to include suppression and rank changes. Introduced error handling for blocked or incorrect credentials in the `event.acknowledge` API call to improve robustness. * Fix error handling in Zabbix server login and acknowledgment Enhanced error handling during login by catching exceptions and logging errors. Prevent execution continuation if login fails and ensure proper response for authentication issues. --------- Co-authored-by: Kimmig, Simon - D0242573 <simon.kimmig@dm.de>
1 parent 61e481b commit cd649d6

2 files changed

Lines changed: 144 additions & 72 deletions

File tree

Nagstamon/Servers/Zabbix.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def __init__(self, **kwds):
4949
GenericServer.__init__(self, **kwds)
5050

5151
# Prepare all urls needed by nagstamon -
52+
self.authentication = conf.servers[self.get_name()].authentication
5253
self.urls = {}
5354
# self.statemap = {}
5455
self.statemap = {
@@ -89,11 +90,16 @@ def _login(self):
8990
self.zapi = ZabbixAPI(server=self.monitor_url, path="", log_level=self.log_level,
9091
validate_certs=self.validate_certs, timeout=self.timeout)
9192
# login if not yet logged in, or if login was refused previously
92-
if not self.zapi.logged_in():
93-
self.zapi.login(self.username, self.password)
94-
except ZabbixAPIException:
95-
result, error = self.error(sys.exc_info())
96-
return Result(result=result, error=error)
93+
if self.authentication == 'bearer':
94+
if not self.zapi.logged_in(bearer=True):
95+
self.zapi.login(self.username, self.password, bearer=True)
96+
elif self.authentication == 'basic':
97+
if not self.zapi.logged_in():
98+
self.zapi.login(self.username, self.password)
99+
else:
100+
raise Exception("Invalid authentication method")
101+
except ZabbixAPIException as e:
102+
raise e
97103

98104
def getLastApp(self, this_item):
99105
if len(this_item) > 0:
@@ -127,7 +133,11 @@ def _get_status(self):
127133
nagitems = {"services": [], "hosts": []}
128134

129135
# Create URLs for the configured filters
130-
self._login()
136+
try:
137+
self._login()
138+
except Exception:
139+
result, error = self.error(sys.exc_info())
140+
return Result(result=result, error=error)
131141
# print(self.name)
132142
# =========================================
133143
# Service
@@ -153,6 +163,8 @@ def _get_status(self):
153163
# Don't really care if this fails, just means we won't exclude any downtimed services
154164
except Exception:
155165
print(sys.exc_info())
166+
result, error = self.error(sys.exc_info())
167+
return Result(result=result, error=error)
156168

157169
try:
158170
try:
@@ -212,7 +224,9 @@ def _get_status(self):
212224
service = e.result.content
213225
ret = e.result
214226
except Exception:
227+
result, error = self.error(sys.exc_info())
215228
print(sys.exc_info())
229+
return Result(result=result, error=error)
216230

217231
hosts = []
218232
# get just involved Hosts.
@@ -457,7 +471,11 @@ def _set_acknowledge(self, host, service, author, comment, sticky, notify, persi
457471
self.debug(server=self.get_name(),
458472
debug="Set Acknowledge Host: " + host + " Service: " + service + " Sticky: " + str(
459473
sticky) + " persistent:" + str(persistent) + " All services: " + str(all_services))
460-
self._login()
474+
try:
475+
self._login()
476+
except Exception:
477+
self.error(sys.exc_info())
478+
return
461479
eventids = set()
462480
unclosable_events = set()
463481
if all_services is None:
@@ -486,6 +504,11 @@ def _set_acknowledge(self, host, service, author, comment, sticky, notify, persi
486504
# 4 - add message
487505
# 8 - change severity
488506
# 16 - unacknowledge event
507+
# 32 - suppress event;
508+
# 64 - unsuppress event;
509+
# 128 - change event rank to cause;
510+
# 256 - change event rank to symptom.
511+
# sticky = close problem # TODO: make visible in GUI
489512
actions = 2
490513
if comment:
491514
actions |= 4
@@ -501,8 +524,14 @@ def _set_acknowledge(self, host, service, author, comment, sticky, notify, persi
501524
else:
502525
if sticky:
503526
actions |= 1
504-
# print("Events to acknowledge: " + str(eventids) + " Close: " + str(actions))
505-
self.zapi.event.acknowledge({'eventids': list(eventids), 'message': comment, 'action': actions})
527+
try:
528+
self.zapi.event.acknowledge({'eventids': list(eventids), 'message': comment, 'action': actions})
529+
except ZabbixAPIException as e:
530+
if "Incorrect user name or password or account is temporarily blocked" in str(e):
531+
self.error(str(e))
532+
return
533+
else:
534+
raise e
506535

507536
def _set_downtime(self, hostname, service, author, comment, fixed, start_time, end_time, hours, minutes):
508537
# Check if there is an associated Application tag with this trigger/item

Nagstamon/thirdparty/zabbix_api.py

Lines changed: 106 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ class Already_Exists(ZabbixAPIException):
9595

9696

9797
class InvalidProtoError(ZabbixAPIException):
98-
9998
""" Recived an invalid proto """
10099
pass
101100

@@ -139,7 +138,10 @@ def __init__(self, server='http://localhost/zabbix', user=httpuser, passwd=httpp
139138
self._setuplogging()
140139
self.set_log_level(log_level)
141140
self.server = server
142-
self.url = server + '/api_jsonrpc.php'
141+
if "/api_jsonrpc.php" not in server:
142+
self.url = server + '/api_jsonrpc.php'
143+
else:
144+
self.url = server
143145
self.proto = self.server.split("://")[0]
144146
# self.proto=proto
145147
self.httpuser = user
@@ -189,7 +191,10 @@ def json_obj(self, method, params={}, auth=True):
189191

190192
return json.dumps(obj)
191193

192-
def login(self, user='', password='', save=True):
194+
def login(self, user='', password='', bearer=False, save=True):
195+
if bearer:
196+
self.auth = password
197+
return
193198
if user != '':
194199
l_user = user
195200
l_password = password
@@ -217,33 +222,94 @@ def login(self, user='', password='', save=True):
217222
result = self.do_request(obj)
218223
self.auth = result['result']
219224

220-
def test_login(self):
221-
if self.auth != '':
225+
def test_login(self, bearer=False):
226+
if bearer:
227+
obj = self.json_obj('user.checkAuthentication', {'token': self.auth})
228+
else:
222229
obj = self.json_obj('user.checkAuthentication', {'sessionid': self.auth})
223-
result = self.do_request(obj)
230+
result = self.do_request(obj, auth_header=False)
231+
if not result['result']:
232+
self.auth = ''
233+
return False # auth hash bad
234+
return True # auth hash good
224235

225-
if not result['result']:
226-
self.auth = ''
227-
return False # auth hash bad
228-
return True # auth hash good
229-
else:
230-
return False
231-
232-
def do_request(self, json_obj):
233-
headers = {'Content-Type': 'application/json-rpc',
234-
'User-Agent': 'python/zabbix_api'}
235-
236-
if self.api_version > '6.4':
237-
headers['Authorization'] = 'Bearer ' + self.auth
238-
if self.httpuser and "Authorization" not in headers.keys():
239-
self.debug(logging.INFO, "HTTP Auth enabled")
240-
auth = 'Basic ' + string.strip(base64.encodestring(self.httpuser + ':' + self.httppasswd))
241-
headers['Authorization'] = auth
236+
def do_request(self, json_obj, auth_header=True):
237+
"""
238+
Send a request to the Zabbix API and handle the response.
239+
240+
Args:
241+
json_obj: The JSON object to send
242+
auth_header: Whether to include authentication headers
243+
244+
Returns:
245+
dict: The JSON response from the API
246+
247+
Raises:
248+
ZabbixAPIException: For various API and connection errors
249+
APITimeout: When the request times out
250+
"""
251+
headers = self._prepare_headers(auth_header)
242252
self.r_query.append(str(json_obj))
243253
self.debug(logging.INFO, "Sending: " + str(json_obj))
244254
self.debug(logging.DEBUG, "Sending headers: " + str(headers))
245255

246256
request = urllib2.Request(url=self.url, data=json_obj.encode('utf-8'), headers=headers)
257+
opener = self._create_url_opener()
258+
urllib2.install_opener(opener)
259+
260+
try:
261+
response = opener.open(request, timeout=self.timeout)
262+
except ssl.SSLError as e:
263+
error_message = e.message if hasattr(e, 'message') else str(e)
264+
raise ZabbixAPIException(f"ssl.SSLError - {error_message}")
265+
except socket.timeout:
266+
raise APITimeout("HTTP read timeout")
267+
except urllib2.URLError as e:
268+
error_message = e.message if hasattr(e, 'message') else str(e)
269+
raise ZabbixAPIException(f"urllib2.URLError - {error_message}")
270+
271+
self.debug(logging.INFO, f"Response Code: {response.code}")
272+
273+
if response.code != 200:
274+
raise ZabbixAPIException(f"HTTP ERROR {response.status}: {response.reason}")
275+
276+
response_data = response.read()
277+
if len(response_data) == 0:
278+
raise ZabbixAPIException("Received zero answer")
279+
280+
try:
281+
json_response = json.loads(response_data.decode('utf-8'))
282+
except ValueError:
283+
self.debug(logging.ERROR, f"Unable to decode response: {response_data}")
284+
raise ZabbixAPIException("Unable to decode answer")
285+
286+
self.debug(logging.DEBUG, f"Response Body: {json_response}")
287+
self.id += 1
288+
289+
if 'error' in json_response:
290+
self._handle_api_error(json_response, json_obj)
291+
292+
return json_response
293+
294+
def _prepare_headers(self, auth_header):
295+
"""Prepare request headers including authentication if needed."""
296+
headers = {
297+
'Content-Type': 'application/json-rpc',
298+
'User-Agent': 'python/zabbix_api'
299+
}
300+
301+
if auth_header:
302+
if self.api_version > '6.4':
303+
headers['Authorization'] = f'Bearer {self.auth}'
304+
elif self.httpuser and "Authorization" not in headers:
305+
self.debug(logging.INFO, "HTTP Auth enabled")
306+
auth_string = base64.encodestring(f"{self.httpuser}:{self.httppasswd}").strip()
307+
headers['Authorization'] = f'Basic {auth_string}'
308+
309+
return headers
310+
311+
def _create_url_opener(self):
312+
"""Create and return the appropriate URL opener based on protocol."""
247313
if self.proto == "https":
248314
if HAS_SSLCONTEXT and not self.validate_certs:
249315
ssl._create_default_https_context = ssl._create_unverified_context
@@ -258,52 +324,29 @@ def do_request(self, json_obj):
258324
http_handler = urllib2.HTTPHandler(debuglevel=0)
259325
opener = urllib2.build_opener(http_handler)
260326
else:
261-
raise ZabbixAPIException("Unknow protocol %s" % self.proto)
327+
raise ZabbixAPIException(f"Unknown protocol {self.proto}")
262328

263-
urllib2.install_opener(opener)
264-
try:
265-
response = opener.open(request, timeout=self.timeout)
266-
except ssl.SSLError as e:
267-
if hasattr(e, 'message'):
268-
e = e.message
269-
raise ZabbixAPIException("ssl.SSLError - %s" % e)
270-
except socket.timeout as e:
271-
raise APITimeout("HTTP read timeout",)
272-
except urllib2.URLError as e:
273-
if hasattr(e, 'message'):
274-
e = e.message
275-
raise ZabbixAPIException("urllib2.URLError - %s" % e)
276-
self.debug(logging.INFO, "Response Code: " + str(response.code))
329+
return opener
277330

278-
# NOTE: Getting a 412 response code means the headers are not in the
279-
# list of allowed headers.
280-
if response.code != 200:
281-
raise ZabbixAPIException("HTTP ERROR %s: %s"
282-
% (response.status, response.reason))
283-
reads = response.read()
284-
if len(reads) == 0:
285-
raise ZabbixAPIException("Received zero answer")
286-
try:
287-
jobj = json.loads(reads.decode('utf-8'))
288-
except ValueError as msg:
289-
print ("unable to decode. returned string: %s" % reads)
290-
raise ZabbixAPIException("Unable to decode answer")
291-
self.debug(logging.DEBUG, "Response Body: " + str(jobj))
331+
def _handle_api_error(self, json_response, json_obj):
332+
"""Handle API error responses and raise appropriate exceptions."""
333+
error = json_response['error']
292334

293-
self.id += 1
335+
# Special handling for authentication failures to prevent sensitive data exposure
336+
if error['code'] == -32500:
337+
raise ZabbixAPIException("Incorrect user name or password or account is temporarily blocked.")
294338

295-
if 'error' in jobj: # some exception
296-
msg = 'Error %s: %s, %s while sending %s' % (jobj['error']['code'],
297-
jobj['error']['message'], jobj['error']['data'], str(json_obj))
298-
if re.search(r'.*already\sexists.*', jobj['error']['data'], re.I): # already exists
299-
raise Already_Exists(msg, jobj['error']['code'])
300-
else:
301-
raise ZabbixAPIException(msg, jobj['error']['code'])
302-
return jobj
339+
error_msg = f"Error {error['code']}: {error['message']}, {error['data']} while sending {json_obj}"
340+
341+
if re.search(r'.*already\sexists.*', error['data'], re.I):
342+
raise Already_Exists(error_msg, error['code'])
343+
else:
344+
raise ZabbixAPIException(error_msg, error['code'])
303345

304-
def logged_in(self):
346+
def logged_in(self, bearer=False):
305347
if self.auth != '':
306-
return True
348+
if self.test_login(bearer):
349+
return True
307350
return False
308351

309352
def get_api_version(self, **options):

0 commit comments

Comments
 (0)