change: added configuration mover

This commit is contained in:
2025-09-26 10:12:37 +02:00
parent c74434e950
commit fd22bbe72b
1089 changed files with 194844 additions and 251 deletions

View File

@@ -0,0 +1,57 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
from .api import ZabbixAPI
from .sender import Sender
from .getter import Getter
from .status import ZabbixStatus
from .types import ItemValue, APIVersion
from .exceptions import ModuleBaseException, APIRequestError, APINotSupported, ProcessingError
from .aiosender import AsyncSender
from .aiogetter import AsyncGetter
try:
__import__('aiohttp')
except ModuleNotFoundError:
class AsyncZabbixAPI():
def __init__(self, *args, **kwargs):
raise ModuleNotFoundError("No module named 'aiohttp'")
else:
from .aioapi import AsyncZabbixAPI
__all__ = (
'ZabbixAPI',
'AsyncZabbixAPI',
'APIVersion',
'Sender',
'AsyncSender',
'ItemValue',
'Getter',
'AsyncGetter',
'ZabbixStatus',
'ModuleBaseException',
'APIRequestError',
'APINotSupported',
'ProcessingError'
)

View File

@@ -0,0 +1,499 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import ssl
import json
import base64
import aiohttp
import logging
from uuid import uuid4
import urllib.request as ul
from textwrap import shorten
from os import environ as env
from urllib.error import URLError
from typing import Callable, Union, Optional, Coroutine, Any
from aiohttp.client_exceptions import ContentTypeError
from .types import APIVersion
from .common import ModuleUtils
from .logger import EmptyHandler, SensitiveFilter
from .exceptions import APIRequestError, APINotSupported, ProcessingError
from .version import __version__, __min_supported__, __max_supported__
log = logging.getLogger(__name__)
log.addHandler(EmptyHandler())
log.addFilter(SensitiveFilter())
class APIObject():
"""Zabbix API object.
Args:
name (str): Zabbix API object name.
parent (class): Zabbix API parent of the object.
"""
def __init__(self, name: str, parent: Callable):
self.object = name
self.parent = parent
def __getattr__(self, name: str) -> Callable:
"""Dynamic creation of an API method.
Args:
name (str): Zabbix API object method name.
Raises:
TypeError: Raises if gets unexpected arguments.
Returns:
Callable: Zabbix API method.
"""
# For compatibility with Python less 3.9 versions
def removesuffix(string: str, suffix: str) -> str:
return str(string[:-len(suffix)]) if suffix and string.endswith(suffix) else string
async def func(*args: Any, **kwargs: Any) -> Any:
if args and kwargs:
await self.__exception(TypeError("Only args or kwargs should be used."))
# Support '_' suffix to avoid conflicts with python keywords
method = removesuffix(self.object, '_') + "." + removesuffix(name, '_')
# Support passing list of ids and params as a dict
params = kwargs or (
(args[0] if type(args[0]) in (list, dict,) else list(args)) if args else None)
log.debug("Executing %s method", method)
need_auth = method not in ModuleUtils.UNAUTH_METHODS
response = await self.parent.send_async_request(
method,
params,
need_auth
)
return response.get('result')
return func
class AsyncZabbixAPI():
"""Provide asynchronous interface for working with Zabbix API.
Args:
url (str, optional): Zabbix API URL. Defaults to `http://localhost/zabbix/api_jsonrpc.php`.
http_user (str, optional): Basic Authentication username. Defaults to `None`.
http_password (str, optional): Basic Authentication password. Defaults to `None`.
skip_version_check (bool, optional): Skip version compatibility check. Defaults to `False`.
validate_certs (bool, optional): Specifying certificate validation. Defaults to `True`.
client_session (aiohttp.ClientSession, optional): Client's session. Defaults to `None`.
timeout (int, optional): Connection timeout to Zabbix API. Defaults to `30`.
"""
__version = None
__use_token = False
__session_id = None
__internal_client = None
def __init__(self, url: Optional[str] = None, token: Optional[str] = None,
user: Optional[str] = None, password: Optional[str] = None,
http_user: Optional[str] = None, http_password: Optional[str] = None,
skip_version_check: bool = False, validate_certs: bool = True,
client_session: Optional[aiohttp.ClientSession] = None, timeout: int = 30):
url = url or env.get('ZABBIX_URL') or 'http://localhost/zabbix/api_jsonrpc.php'
self.url = ModuleUtils.check_url(url)
self.validate_certs = validate_certs
self.timeout = timeout
self.__token = token
self.__user = user
self.__password = password
client_params: dict = {}
if client_session is None:
client_params["connector"] = aiohttp.TCPConnector(
ssl=self.validate_certs
)
# HTTP Auth unsupported since Zabbix 7.2
if http_user and http_password:
client_params["auth"] = aiohttp.BasicAuth(
login=http_user,
password=http_password
)
self.__internal_client = aiohttp.ClientSession(**client_params)
self.client_session = self.__internal_client
else:
if http_user and http_password:
raise AttributeError(
"Parameters http_user/http_password shouldn't be used with client_session"
)
self.client_session = client_session
self.__check_version(skip_version_check)
if self.version > 7.0 and http_user and http_password:
self.__close_session()
raise APINotSupported("HTTP authentication unsupported since Zabbix 7.2.")
def __getattr__(self, name: str) -> Callable:
"""Dynamic creation of an API object.
Args:
name (str): Zabbix API method name.
Returns:
APIObject: Zabbix API object instance.
"""
return APIObject(name, self)
async def __aenter__(self) -> Callable:
return await self.login()
async def __aexit__(self, *args) -> None:
await self.logout()
def __await__(self) -> Coroutine:
return self.login().__await__()
async def __aclose_session(self) -> None:
if self.__internal_client:
await self.__internal_client.close()
async def __exception(self, exc) -> None:
await self.__aclose_session()
raise exc
def __close_session(self) -> None:
if self.__internal_client:
self.__internal_client._connector.close()
def api_version(self) -> APIVersion:
"""Return object of Zabbix API version.
Returns:
APIVersion: Object of Zabbix API version
"""
if self.__version is None:
self.__version = APIVersion(
self.send_sync_request('apiinfo.version', {}, False).get('result')
)
return self.__version
@property
def version(self) -> APIVersion:
"""Return object of Zabbix API version.
Returns:
APIVersion: Object of Zabbix API version.
"""
return self.api_version()
async def login(self, token: Optional[str] = None, user: Optional[str] = None,
password: Optional[str] = None) -> Callable:
"""Login to Zabbix API.
Args:
token (str, optional): Zabbix API token. Defaults to `None`.
user (str, optional): Zabbix API username. Defaults to `None`.
password (str, optional): Zabbix API user's password. Defaults to `None`.
"""
token = token or self.__token or env.get('ZABBIX_TOKEN') or None
user = user or self.__user or env.get('ZABBIX_USER') or None
password = password or self.__password or env.get('ZABBIX_PASSWORD') or None
if token:
if self.version < 5.4:
await self.__exception(APINotSupported(
message="Token usage",
version=self.version
))
if user or password:
await self.__exception(
ProcessingError("Token cannot be used with username and password")
)
self.__use_token = True
self.__session_id = token
return self
if not user:
await self.__exception(ProcessingError("Username is missing"))
if not password:
await self.__exception(ProcessingError("User password is missing"))
if self.version < 5.4:
user_cred = {
"user": user,
"password": password
}
else:
user_cred = {
"username": user,
"password": password
}
log.debug(
"Login to Zabbix API using username:%s password:%s", user, ModuleUtils.HIDING_MASK
)
self.__use_token = False
self.__session_id = await self.user.login(**user_cred)
log.debug("Connected to Zabbix API version %s: %s", self.version, self.url)
return self
async def logout(self) -> None:
"""Logout from Zabbix API."""
if self.__session_id:
if self.__use_token:
self.__session_id = None
self.__use_token = False
await self.__aclose_session()
return
log.debug("Logout from Zabbix API")
await self.user.logout()
self.__session_id = None
await self.__aclose_session()
else:
log.debug("You're not logged in Zabbix API")
async def check_auth(self) -> bool:
"""Check authentication status in Zabbix API.
Returns:
bool: User authentication status (`True`, `False`)
"""
if not self.__session_id:
log.debug("You're not logged in Zabbix API")
return False
if self.__use_token:
log.debug("Check auth session using token in Zabbix API")
refresh_resp = await self.user.checkAuthentication(token=self.__session_id)
else:
log.debug("Check auth session using sessionid in Zabbix API")
refresh_resp = await self.user.checkAuthentication(sessionid=self.__session_id)
return bool(refresh_resp.get('userid'))
def __prepare_request(self, method: str, params: Optional[dict] = None,
need_auth=True) -> Union[dict, dict]:
request = {
'jsonrpc': '2.0',
'method': method,
'params': params or {},
'id': str(uuid4()),
}
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json-rpc',
'User-Agent': f"{__name__}/{__version__}"
}
if need_auth:
if not self.__session_id:
raise ProcessingError("You're not logged in Zabbix API")
if self.version < 6.4:
request['auth'] = self.__session_id
elif self.version <= 7.0 and self.client_session._default_auth is not None:
request['auth'] = self.__session_id
else:
headers["Authorization"] = f"Bearer {self.__session_id}"
log.debug(
"Sending request to %s with body: %s",
self.url,
request
)
return (request, headers)
def __check_response(self, method: str, response: dict) -> dict:
if method not in ModuleUtils.FILES_METHODS:
log.debug(
"Received response body: %s",
response
)
else:
debug_json = response.copy()
if debug_json.get('result'):
debug_json['result'] = shorten(debug_json['result'], 200, placeholder='...')
log.debug(
"Received response body (clipped): %s",
json.dumps(debug_json, indent=4, separators=(',', ': '))
)
if 'error' in response:
err = response['error'].copy()
err['body'] = response.copy()
raise APIRequestError(err)
return response
async def send_async_request(self, method: str, params: Optional[dict] = None,
need_auth=True) -> dict:
"""Function for sending asynchronous request to Zabbix API.
Args:
method (str): Zabbix API method name.
params (dict, optional): Params for request body. Defaults to `None`.
need_auth (bool, optional): Authorization using flag. Defaults to `False`.
Raises:
ProcessingError: Wrapping built-in exceptions during request processing.
APIRequestError: Wrapping errors from Zabbix API.
Returns:
dict: Dictionary with Zabbix API response.
"""
try:
request_json, headers = self.__prepare_request(method, params, need_auth)
except ProcessingError as err:
await self.__exception(err)
resp = await self.client_session.post(
self.url,
json=request_json,
headers=headers,
timeout=self.timeout
)
resp.raise_for_status()
try:
resp_json = await resp.json()
except ContentTypeError as err:
await self.__exception(ProcessingError(f"Unable to connect to {self.url}:", err))
except ValueError as err:
await self.__exception(ProcessingError("Unable to parse json:", err))
try:
return self.__check_response(method, resp_json)
except APIRequestError as err:
await self.__exception(err)
def send_sync_request(self, method: str, params: Optional[dict] = None,
need_auth=True) -> dict:
"""Function for sending synchronous request to Zabbix API.
Args:
method (str): Zabbix API method name.
params (dict, optional): Params for request body. Defaults to `None`.
need_auth (bool, optional): Authorization using flag. Defaults to `False`.
Raises:
ProcessingError: Wrapping built-in exceptions during request processing.
APIRequestError: Wrapping errors from Zabbix API.
Returns:
dict: Dictionary with Zabbix API response.
"""
request_json, headers = self.__prepare_request(method, params, need_auth)
# HTTP Auth unsupported since Zabbix 7.2
basic_auth = self.client_session._default_auth
if basic_auth is not None:
headers["Authorization"] = "Basic " + base64.b64encode(
f"{basic_auth.login}:{basic_auth.password}".encode()
).decode()
req = ul.Request(
self.url,
data=json.dumps(request_json).encode("utf-8"),
headers=headers,
method='POST'
)
req.timeout = self.timeout
# Disable SSL certificate validation if needed.
if not self.validate_certs:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
elif not isinstance(self.client_session._connector._ssl, bool):
ctx = self.client_session._connector._ssl
else:
ctx = None
try:
resp = ul.urlopen(req, context=ctx)
resp_json = json.loads(resp.read().decode('utf-8'))
except URLError as err:
self.__close_session()
raise ProcessingError(f"Unable to connect to {self.url}:", err) from None
except ValueError as err:
self.__close_session()
raise ProcessingError("Unable to parse json:", err) from None
except Exception as err:
self.__close_session()
raise ProcessingError(err) from None
return self.__check_response(method, resp_json)
def __check_version(self, skip_check: bool) -> None:
skip_check_help = "If you're sure zabbix_utils will work properly with your current \
Zabbix version you can skip this check by \
specifying skip_version_check=True when create ZabbixAPI object."
if self.version < __min_supported__:
if skip_check:
log.debug(
"Version of Zabbix API [%s] is less than the library supports. %s",
self.version,
"Further library use at your own risk!"
)
else:
raise APINotSupported(
f"Version of Zabbix API [{self.version}] is not supported by the library. " +
f"The oldest supported version is {__min_supported__}.0. " + skip_check_help
)
if self.version > __max_supported__:
if skip_check:
log.debug(
"Version of Zabbix API [%s] is more than the library was tested on. %s",
self.version,
"Recommended to update the library. Further library use at your own risk!"
)
else:
raise APINotSupported(
f"Version of Zabbix API [{self.version}] was not tested with the library. " +
f"The latest tested version is {__max_supported__}.0. " + skip_check_help
)

View File

@@ -0,0 +1,140 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import ssl
import socket
import asyncio
import logging
from typing import Callable, Optional
from .logger import EmptyHandler
from .types import AgentResponse
from .common import ZabbixProtocol
from .exceptions import ProcessingError
log = logging.getLogger(__name__)
log.addHandler(EmptyHandler())
class AsyncGetter():
"""Zabbix get asynchronous implementation.
Args:
host (str, optional): Zabbix agent address. Defaults to `'127.0.0.1'`.
port (int, optional): Zabbix agent port. Defaults to `10050`.
timeout (int, optional): Connection timeout value. Defaults to `10`.
source_ip (str, optional): IP from which to establish connection. Defaults to `None`.
ssl_context (Callable, optional): Func(), returned prepared ssl.SSLContext. \
Defaults to `None`.
"""
def __init__(self, host: str = '127.0.0.1', port: int = 10050, timeout: int = 10,
source_ip: Optional[str] = None, ssl_context: Optional[Callable] = None):
self.host = host
self.port = port
self.timeout = timeout
self.source_ip = source_ip
self.ssl_context = ssl_context
if self.ssl_context:
if not isinstance(self.ssl_context, Callable):
raise TypeError('Value "ssl_context" should be a function.')
async def __get_response(self, reader: asyncio.StreamReader) -> Optional[str]:
result = await ZabbixProtocol.parse_async_packet(reader, log, ProcessingError)
log.debug('Received data: %s', result)
return result
async def get(self, key: str) -> Optional[str]:
"""Gets item value from Zabbix agent by specified key.
Args:
key (str): Zabbix item key.
Returns:
str: Value from Zabbix agent for specified key.
"""
packet = ZabbixProtocol.create_packet(key, log)
connection_params = {
"host": self.host,
"port": self.port
}
if self.source_ip:
connection_params['local_addr'] = (self.source_ip, 0)
if self.ssl_context:
connection_params['ssl'] = self.ssl_context()
if not isinstance(connection_params['ssl'], ssl.SSLContext):
raise TypeError(
'Function "ssl_context" must return "ssl.SSLContext".') from None
connection = asyncio.open_connection(**connection_params)
try:
reader, writer = await asyncio.wait_for(connection, timeout=self.timeout)
writer.write(packet)
await writer.drain()
except asyncio.TimeoutError as err:
log.error(
'The connection to %s timed out after %d seconds',
f"{self.host}:{self.port}",
self.timeout
)
raise err
except (ConnectionRefusedError, socket.gaierror) as err:
log.error(
'An error occurred while trying to connect to %s: %s',
f"{self.host}:{self.port}",
getattr(err, 'msg', str(err))
)
raise err
except (OSError, socket.error) as err:
log.warning(
'An error occurred while trying to send to %s: %s',
f"{self.host}:{self.port}",
getattr(err, 'msg', str(err))
)
raise err
try:
response = await self.__get_response(reader)
except (ConnectionResetError, asyncio.exceptions.IncompleteReadError) as err:
log.debug('Get value error: %s', err)
log.warning('Check access restrictions in Zabbix agent configuration.')
raise err
log.debug('Response from [%s:%s]: %s', self.host, self.port, response)
writer.close()
await writer.wait_closed()
return AgentResponse(response)

View File

@@ -0,0 +1,315 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import ssl
import json
import socket
import asyncio
import logging
import configparser
from typing import Callable, Union, Optional, Tuple
from .logger import EmptyHandler
from .common import ZabbixProtocol
from .exceptions import ProcessingError
from .types import TrapperResponse, ItemValue, Cluster, Node
log = logging.getLogger(__name__)
log.addHandler(EmptyHandler())
class AsyncSender():
"""Zabbix sender asynchronous implementation.
Args:
server (str, optional): Zabbix server address. Defaults to `'127.0.0.1'`.
port (int, optional): Zabbix server port. Defaults to `10051`.
use_config (bool, optional): Specifying configuration use. Defaults to `False`.
timeout (int, optional): Connection timeout value. Defaults to `10`.
use_ipv6 (bool, optional): Specifying IPv6 use instead of IPv4. Defaults to `False`.
source_ip (str, optional): IP from which to establish connection. Defaults to `None`.
chunk_size (int, optional): Number of packets in one chunk. Defaults to `250`.
clusters (tuple|list, optional): List of Zabbix clusters. Defaults to `None`.
ssl_context (Callable, optional): Func(`tls`), returned prepared ssl.SSLContext. \
Defaults to `None`.
compression (bool, optional): Specifying compression use. Defaults to `False`.
config_path (str, optional): Path to Zabbix agent configuration file. Defaults to \
`/etc/zabbix/zabbix_agentd.conf`.
"""
def __init__(self, server: Optional[str] = None, port: int = 10051,
use_config: bool = False, timeout: int = 10,
use_ipv6: bool = False, source_ip: Optional[str] = None,
chunk_size: int = 250, clusters: Union[tuple, list] = None,
ssl_context: Optional[Callable] = None, compression: bool = False,
config_path: Optional[str] = '/etc/zabbix/zabbix_agentd.conf'):
self.timeout = timeout
self.use_ipv6 = use_ipv6
self.tls = {}
self.host = None
self.source_ip = None
self.chunk_size = chunk_size
self.compression = compression
if ssl_context is not None:
if not isinstance(ssl_context, Callable):
raise TypeError('Value "ssl_context" should be a function.') from None
self.ssl_context = ssl_context
if source_ip is not None:
self.source_ip = source_ip
if use_config:
self.clusters = []
self.__load_config(config_path)
return
if clusters is not None:
if not (isinstance(clusters, tuple) or isinstance(clusters, list)):
raise TypeError('Value "clusters" should be a tuple or a list.') from None
clusters = clusters.copy()
if server is not None:
clusters.append([f"{server}:{port}"])
self.clusters = [Cluster(c) for c in clusters]
else:
self.clusters = [Cluster([f"{server or '127.0.0.1'}:{port}"])]
def __read_config(self, config: configparser.SectionProxy) -> None:
server_row = config.get('ServerActive') or config.get('Server') or '127.0.0.1:10051'
for cluster in server_row.split(','):
self.clusters.append(Cluster(cluster.strip().split(';')))
self.host = config.get('Hostname')
if 'SourceIP' in config:
self.source_ip = config.get('SourceIP')
for key in config:
if key.startswith('tls'):
self.tls[key] = config.get(key)
def __load_config(self, filepath: str) -> None:
config = configparser.ConfigParser(strict=False)
with open(filepath, 'r', encoding='utf-8') as cfg:
config.read_string('[root]\n' + cfg.read())
self.__read_config(config['root'])
async def __get_response(self, reader: asyncio.StreamReader) -> Optional[str]:
try:
result = json.loads(
await ZabbixProtocol.parse_async_packet(reader, log, ProcessingError)
)
except json.decoder.JSONDecodeError as err:
log.debug('Unexpected response was received from Zabbix.')
raise err
log.debug('Received data: %s', result)
return result
def __create_request(self, items: list) -> dict:
return {
"request": "sender data",
"data": [i.to_json() for i in items]
}
async def __send_to_cluster(self, cluster: Cluster, packet: bytes) -> Optional[Tuple[Node, dict]]:
active_node = None
active_node_idx = 0
for i, node in enumerate(cluster.nodes):
log.debug('Trying to send data to %s', node)
connection_params = {
"host": node.address,
"port": node.port
}
if self.source_ip:
connection_params['local_addr'] = (self.source_ip, 0)
if self.ssl_context is not None:
connection_params['ssl'] = self.ssl_context(self.tls)
if not isinstance(connection_params['ssl'], ssl.SSLContext):
raise TypeError(
'Function "ssl_context" must return "ssl.SSLContext".') from None
connection = asyncio.open_connection(**connection_params)
try:
reader, writer = await asyncio.wait_for(connection, timeout=self.timeout)
except asyncio.TimeoutError:
log.debug(
'The connection to %s timed out after %d seconds',
node,
self.timeout
)
except (ConnectionRefusedError, socket.gaierror) as err:
log.debug(
'An error occurred while trying to connect to %s: %s',
node,
getattr(err, 'msg', str(err))
)
else:
active_node_idx = i
if i > 0:
cluster.nodes[0], cluster.nodes[i] = cluster.nodes[i], cluster.nodes[0]
active_node_idx = 0
active_node = node
break
if active_node is None:
log.error(
'Couldn\'t connect to all of cluster nodes: %s',
str(list(cluster.nodes))
)
raise ProcessingError(
f"Couldn't connect to all of cluster nodes: {list(cluster.nodes)}"
)
try:
writer.write(packet)
send_data = writer.drain()
await asyncio.wait_for(send_data, timeout=self.timeout)
except (asyncio.TimeoutError, socket.timeout) as err:
log.error(
'The connection to %s timed out after %d seconds while trying to send',
active_node,
self.timeout
)
writer.close()
await writer.wait_closed()
raise err
except (OSError, socket.error) as err:
log.warning(
'An error occurred while trying to send to %s: %s',
active_node,
getattr(err, 'msg', str(err))
)
writer.close()
await writer.wait_closed()
raise err
try:
response = await self.__get_response(reader)
except (ConnectionResetError, asyncio.exceptions.IncompleteReadError) as err:
log.debug('Get value error: %s', err)
raise err
log.debug('Response from %s: %s', active_node, response)
if response and response.get('response') != 'success':
if response.get('redirect'):
log.debug(
'Packet was redirected from %s to %s. Proxy group revision: %s.',
active_node,
response['redirect']['address'],
response['redirect']['revision']
)
cluster.nodes[active_node_idx] = Node(*response['redirect']['address'].split(':'))
active_node, response = await self.__send_to_cluster(cluster, packet)
else:
raise ProcessingError(response) from None
writer.close()
await writer.wait_closed()
return active_node, response
async def __chunk_send(self, items: list) -> dict:
responses = {}
packet = ZabbixProtocol.create_packet(self.__create_request(items), log, self.compression)
for cluster in self.clusters:
active_node, response = await self.__send_to_cluster(cluster, packet)
responses[active_node] = response
return responses
async def send(self, items: list) -> TrapperResponse:
"""Sends packets and receives an answer from Zabbix.
Args:
items (list): List of ItemValue objects.
Returns:
TrapperResponse: Response from Zabbix server/proxy.
"""
# Split the list of items into chunks of size self.chunk_size.
chunks = [items[i:i + self.chunk_size] for i in range(0, len(items), self.chunk_size)]
# Merge responses into a single TrapperResponse object.
result = TrapperResponse()
# TrapperResponse details for each node and chunk.
result.details = {}
for i, chunk in enumerate(chunks):
if not all(isinstance(item, ItemValue) for item in chunk):
log.debug('Received unexpected item list. It must be a list of \
ItemValue objects: %s', json.dumps(chunk))
raise ProcessingError(f"Received unexpected item list. \
It must be a list of ItemValue objects: {json.dumps(chunk)}")
resp_by_node = await self.__chunk_send(chunk)
node_step = 1
for node, resp in resp_by_node.items():
try:
result.add(resp, (i + 1) * node_step)
except ProcessingError as err:
log.debug(err)
raise ProcessingError(err) from None
node_step += 1
if node not in result.details:
result.details[node] = []
result.details[node].append(TrapperResponse(i+1).add(resp))
return result
async def send_value(self, host: str, key: str,
value: str, clock: Optional[int] = None,
ns: Optional[int] = None) -> TrapperResponse:
"""Sends one value and receives an answer from Zabbix.
Args:
host (str): Specify host name the item belongs to (as registered in Zabbix frontend).
key (str): Specify item key to send value to.
value (str): Specify item value.
clock (int, optional): Specify time in Unix timestamp format. Defaults to `None`.
ns (int, optional): Specify time expressed in nanoseconds. Defaults to `None`.
Returns:
TrapperResponse: Response from Zabbix server/proxy.
"""
return await self.send([ItemValue(host or self.host or '', key, value, clock, ns)])

View File

@@ -0,0 +1,420 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import ssl
import json
import base64
import logging
import urllib.request as ul
from textwrap import shorten
from uuid import uuid4
from os import environ as env
from urllib.error import URLError
from typing import Callable, Optional, Any
from .types import APIVersion
from .common import ModuleUtils
from .logger import EmptyHandler, SensitiveFilter
from .exceptions import APIRequestError, APINotSupported, ProcessingError
from .version import __version__, __min_supported__, __max_supported__
log = logging.getLogger(__name__)
log.addHandler(EmptyHandler())
log.addFilter(SensitiveFilter())
class APIObject():
"""Zabbix API object.
Args:
name (str): Zabbix API object name.
parent (class): Zabbix API parent of the object.
"""
def __init__(self, name: str, parent: Callable):
self.object = name
self.parent = parent
def __getattr__(self, name: str) -> Callable:
"""Dynamic creation of an API method.
Args:
name (str): Zabbix API object method name.
Raises:
TypeError: Raises if gets unexpected arguments.
Returns:
Callable: Zabbix API method.
"""
# For compatibility with Python less 3.9 versions
def removesuffix(string: str, suffix: str) -> str:
return str(string[:-len(suffix)]) if suffix and string.endswith(suffix) else string
def func(*args: Any, **kwargs: Any) -> Any:
if args and kwargs:
raise TypeError("Only args or kwargs should be used.")
# Support '_' suffix to avoid conflicts with python keywords
method = removesuffix(self.object, '_') + "." + removesuffix(name, '_')
# Support passing list of ids and params as a dict
params = kwargs or (
(args[0] if type(args[0]) in (list, dict,) else list(args)) if args else None)
log.debug("Executing %s method", method)
need_auth = method not in ModuleUtils.UNAUTH_METHODS
return self.parent.send_api_request(
method,
params,
need_auth
).get('result')
return func
class ZabbixAPI():
"""Provide interface for working with Zabbix API.
Args:
url (str, optional): Zabbix API URL. Defaults to `http://localhost/zabbix/api_jsonrpc.php`.
token (str, optional): Zabbix API token. Defaults to `None`.
user (str, optional): Zabbix API username. Defaults to `None`.
password (str, optional): Zabbix API user's password. Defaults to `None`.
http_user (str, optional): Basic Authentication username. Defaults to `None`.
http_password (str, optional): Basic Authentication password. Defaults to `None`.
skip_version_check (bool, optional): Skip version compatibility check. Defaults to `False`.
validate_certs (bool, optional): Specifying certificate validation. Defaults to `True`.
timeout (int, optional): Connection timeout to Zabbix API. Defaults to `30`.
"""
__version = None
__use_token = False
__session_id = None
__basic_cred = None
def __init__(self, url: Optional[str] = None, token: Optional[str] = None,
user: Optional[str] = None, password: Optional[str] = None,
http_user: Optional[str] = None, http_password: Optional[str] = None,
skip_version_check: bool = False, validate_certs: bool = True,
ssl_context: Optional[ssl.SSLContext] = None, timeout: int = 30):
url = url or env.get('ZABBIX_URL') or 'http://localhost/zabbix/api_jsonrpc.php'
user = user or env.get('ZABBIX_USER') or None
password = password or env.get('ZABBIX_PASSWORD') or None
token = token or env.get('ZABBIX_TOKEN') or None
self.url = ModuleUtils.check_url(url)
self.validate_certs = validate_certs
self.timeout = timeout
# HTTP Auth unsupported since Zabbix 7.2
if http_user and http_password:
self.__basic_auth(http_user, http_password)
if ssl_context is not None:
if not isinstance(ssl_context, ssl.SSLContext):
raise TypeError(
'Parameter "ssl_context" must be an "ssl.SSLContext".') from None
self.ssl_context = ssl_context
self.__check_version(skip_version_check)
if self.version > 7.0 and http_user and http_password:
raise APINotSupported("HTTP authentication unsupported since Zabbix 7.2.")
if token or user or password:
self.login(token, user, password)
def __getattr__(self, name: str) -> Callable:
"""Dynamic creation of an API object.
Args:
name (str): Zabbix API method name.
Returns:
APIObject: Zabbix API object instance.
"""
return APIObject(name, self)
def __enter__(self) -> Callable:
return self
def __exit__(self, *args) -> None:
self.logout()
def __basic_auth(self, user: str, password: str) -> None:
"""Enable Basic Authentication using.
Args:
user (str): Basic Authentication username.
password (str): Basic Authentication password.
"""
log.debug(
"Enable Basic Authentication with username:%s password:%s",
user,
ModuleUtils.HIDING_MASK
)
self.__basic_cred = base64.b64encode(
f"{user}:{password}".encode()
).decode()
def api_version(self) -> APIVersion:
"""Return object of Zabbix API version.
Returns:
APIVersion: Object of Zabbix API version
"""
if self.__version is None:
self.__version = APIVersion(self.apiinfo.version())
return self.__version
@property
def version(self) -> APIVersion:
"""Return object of Zabbix API version.
Returns:
APIVersion: Object of Zabbix API version.
"""
return self.api_version()
def login(self, token: Optional[str] = None, user: Optional[str] = None,
password: Optional[str] = None) -> None:
"""Login to Zabbix API.
Args:
token (str, optional): Zabbix API token. Defaults to `None`.
user (str, optional): Zabbix API username. Defaults to `None`.
password (str, optional): Zabbix API user's password. Defaults to `None`.
"""
if token:
if self.version < 5.4:
raise APINotSupported(
message="Token usage",
version=self.version
)
if user or password:
raise ProcessingError(
"Token cannot be used with username and password")
self.__use_token = True
self.__session_id = token
return
if not user:
raise ProcessingError("Username is missing")
if not password:
raise ProcessingError("User password is missing")
if self.version < 5.4:
user_cred = {
"user": user,
"password": password
}
else:
user_cred = {
"username": user,
"password": password
}
log.debug(
"Login to Zabbix API using username:%s password:%s", user, ModuleUtils.HIDING_MASK
)
self.__use_token = False
self.__session_id = self.user.login(**user_cred)
log.debug("Connected to Zabbix API version %s: %s", self.version, self.url)
def logout(self) -> None:
"""Logout from Zabbix API."""
if self.__session_id:
if self.__use_token:
self.__session_id = None
self.__use_token = False
return
log.debug("Logout from Zabbix API")
self.user.logout()
self.__session_id = None
else:
log.debug("You're not logged in Zabbix API")
def check_auth(self) -> bool:
"""Check authentication status in Zabbix API.
Returns:
bool: User authentication status (`True`, `False`)
"""
if not self.__session_id:
log.debug("You're not logged in Zabbix API")
return False
if self.__use_token:
log.debug("Check auth session using token in Zabbix API")
refresh_resp = self.user.checkAuthentication(token=self.__session_id)
else:
log.debug("Check auth session using sessionid in Zabbix API")
refresh_resp = self.user.checkAuthentication(sessionid=self.__session_id)
return bool(refresh_resp.get('userid'))
def send_api_request(self, method: str, params: Optional[dict] = None,
need_auth=True) -> dict:
"""Function for sending request to Zabbix API.
Args:
method (str): Zabbix API method name.
params (dict, optional): Params for request body. Defaults to `None`.
need_auth (bool, optional): Authorization using flag. Defaults to `False`.
Raises:
ProcessingError: Wrapping built-in exceptions during request processing.
APIRequestError: Wrapping errors from Zabbix API.
Returns:
dict: Dictionary with Zabbix API response.
"""
request_json = {
'jsonrpc': '2.0',
'method': method,
'params': params or {},
'id': str(uuid4()),
}
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json-rpc',
'User-Agent': f"{__name__}/{__version__}"
}
if need_auth:
if not self.__session_id:
raise ProcessingError("You're not logged in Zabbix API")
if self.version < 6.4:
request_json['auth'] = self.__session_id
elif self.version <= 7.0 and self.__basic_cred is not None:
request_json['auth'] = self.__session_id
else:
headers["Authorization"] = f"Bearer {self.__session_id}"
if self.__basic_cred is not None:
headers["Authorization"] = f"Basic {self.__basic_cred}"
log.debug(
"Sending request to %s with body: %s",
self.url,
request_json
)
req = ul.Request(
self.url,
data=json.dumps(request_json).encode("utf-8"),
headers=headers,
method='POST'
)
req.timeout = self.timeout
# Disable SSL certificate validation if needed.
if not self.validate_certs:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
elif self.ssl_context is not None:
ctx = self.ssl_context
else:
ctx = None
try:
resp = ul.urlopen(req, context=ctx)
resp_json = json.loads(resp.read().decode('utf-8'))
except URLError as err:
raise ProcessingError(f"Unable to connect to {self.url}:", err) from None
except ValueError as err:
raise ProcessingError("Unable to parse json:", err) from None
if method not in ModuleUtils.FILES_METHODS:
log.debug(
"Received response body: %s",
resp_json
)
else:
debug_json = resp_json.copy()
if debug_json.get('result'):
debug_json['result'] = shorten(debug_json['result'], 200, placeholder='...')
log.debug(
"Received response body (clipped): %s",
json.dumps(debug_json, indent=4, separators=(',', ': '))
)
if 'error' in resp_json:
err = resp_json['error'].copy()
err['body'] = request_json.copy()
raise APIRequestError(err)
return resp_json
def __check_version(self, skip_check: bool) -> None:
skip_check_help = "If you're sure zabbix_utils will work properly with your current \
Zabbix version you can skip this check by \
specifying skip_version_check=True when create ZabbixAPI object."
if self.version < __min_supported__:
if skip_check:
log.debug(
"Version of Zabbix API [%s] is less than the library supports. %s",
self.version,
"Further library use at your own risk!"
)
else:
raise APINotSupported(
f"Version of Zabbix API [{self.version}] is not supported by the library. " +
f"The oldest supported version is {__min_supported__}.0. " + skip_check_help
)
if self.version > __max_supported__:
if skip_check:
log.debug(
"Version of Zabbix API [%s] is more than the library was tested on. %s",
self.version,
"Recommended to update the library. Further library use at your own risk!"
)
else:
raise APINotSupported(
f"Version of Zabbix API [{self.version}] was not tested with the library. " +
f"The latest tested version is {__max_supported__}.0. " + skip_check_help
)

View File

@@ -0,0 +1,331 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import re
import json
import zlib
import struct
import asyncio
from textwrap import shorten
from logging import Logger
from socket import socket
from typing import Match, Union
class ModuleUtils():
# Hidding mask for sensitive data
HIDING_MASK = "*" * 8
# The main php-file of Zabbix API
JSONRPC_FILE = 'api_jsonrpc.php'
# Methods working without auth token
UNAUTH_METHODS = ('apiinfo.version', 'user.login', 'user.checkAuthentication')
# Methods returning files contents
FILES_METHODS = ('configuration.export',)
# List of private fields and regular expressions to hide them
PRIVATE_FIELDS = {
"token": r"^.+$",
"auth": r"^.+$",
"passwd": r"^.+$",
"sessionid": r"^.+$",
"password": r"^.+$",
"current_passwd": r"^.+$",
"result": r"^[A-Za-z0-9]{32}$", # To hide only token or sessionid in result
}
@classmethod
def check_url(cls, url: str) -> str:
"""Check url completeness
Args:
url (str): Unchecked URL of Zabbix API
Returns:
str: Checked URL of Zabbix API
"""
if not url.endswith(cls.JSONRPC_FILE):
url += cls.JSONRPC_FILE if url[-1] == '/' else '/' + cls.JSONRPC_FILE
if not url.startswith('http'):
url = 'http://' + url
return url
@classmethod
def mask_secret(cls, string: str, show_len: int = 4) -> str:
"""Replace the most part of string to hiding mask.
Args:
string (str): Raw string with without hiding.
show_len (int, optional): Number of signs shown on each side of the string. \
Defaults to 4.
Returns:
str: String with hiding part.
"""
# If show_len is 0 or the length of the string is smaller than the hiding mask length
# and show_len from both sides of the string, return only hiding mask.
if show_len == 0 or len(string) <= (len(cls.HIDING_MASK) + show_len*2):
return cls.HIDING_MASK
# Return the string with the hiding mask, surrounded by the specified number of characters
# to display on each side of the string.
return f"{string[:show_len]}{cls.HIDING_MASK}{string[-show_len:]}"
@classmethod
def hide_private(cls, input_data: dict, fields: dict = None) -> dict:
"""Hide private data Zabbix info (e.g. token, password)
Args:
input_data (dict): Input dictionary with private fields.
fields (dict): Dictionary of private fields and their filtering regexps.
Returns:
dict: Result dictionary without private data.
"""
private_fields = fields if fields is not None else cls.PRIVATE_FIELDS
if not isinstance(input_data, dict):
raise TypeError(f"Unsupported data type '{type(input_data).__name__}', \
only 'dict' is expected")
def gen_repl(match: Match):
return cls.mask_secret(match.group(0))
def hide_str(k, v):
return re.sub(private_fields[k], gen_repl, v)
def hide_dict(v):
return cls.hide_private(v, private_fields)
def hide_list(k, v):
result = []
for item in v:
if isinstance(item, dict):
result.append(hide_dict(item))
continue
if isinstance(item, list):
result.append(hide_list(k, item))
continue
if isinstance(item, str):
if k.rstrip('s') in private_fields:
result.append(hide_str(k.rstrip('s'), item))
continue
# The 'result' regex is used to hide only token or
# sessionid format for unknown values
if 'result' in private_fields:
result.append(hide_str('result', item))
continue
result.append(item)
return result
result_data = input_data.copy()
for key, value in result_data.items():
if isinstance(value, str):
if key in private_fields:
result_data[key] = hide_str(key, value)
if isinstance(value, dict):
result_data[key] = hide_dict(value)
if isinstance(value, list):
result_data[key] = hide_list(key, value)
return result_data
class ZabbixProtocol():
ZABBIX_PROTOCOL = b'ZBXD'
HEADER_SIZE = 13
@classmethod
def __prepare_request(cls, data: Union[bytes, str, list, dict]) -> bytes:
if isinstance(data, bytes):
return data
if isinstance(data, str):
return data.encode("utf-8")
if isinstance(data, list) or isinstance(data, dict):
return json.dumps(data, ensure_ascii=False).encode("utf-8")
raise TypeError("Unsupported data type, only 'bytes', 'str', 'list' or 'dict' is expected")
@classmethod
def create_packet(cls, payload: Union[bytes, str, list, dict],
log: Logger, compression: bool = False) -> bytes:
"""Create a packet for sending via the Zabbix protocol.
Args:
payload (bytes|str|list|dict): Payload of the future packet
log (Logger): Logger object
compression (bool, optional): Compression use flag. Defaults to `False`.
Returns:
bytes: Generated Zabbix protocol packet
"""
request = cls.__prepare_request(payload)
log.debug('Request data: %s', shorten(request.decode("utf-8"), 200, placeholder='...'))
# 0x01 - Zabbix communications protocol
flags = 0x01
datalen = len(request)
reserved = 0
if compression:
# 0x02 - Using packet compression mode
flags |= 0x02
reserved = datalen
request = zlib.compress(request)
datalen = len(request)
header = struct.pack('<4sBII', cls.ZABBIX_PROTOCOL, flags, datalen, reserved)
packet = header + request
log.debug('Content of the packet: %s', shorten(str(packet), 200, placeholder='...'))
return packet
@classmethod
def receive_packet(cls, conn: socket, size: int, log: Logger) -> bytes:
"""Receive a Zabbix protocol packet.
Args:
conn (socket): Opened socket connection
size (int): Expected packet size
log (Logger): Logger object
Returns:
bytes: Received packet content
"""
buf = b''
while len(buf) < size:
chunk = conn.recv(size - len(buf))
if not chunk:
log.debug("Socket connection was closed before receiving expected amount of data.")
break
buf += chunk
return buf
@classmethod
def parse_sync_packet(cls, conn: socket, log: Logger, exception) -> str:
"""Parse a received synchronously Zabbix protocol packet.
Args:
conn (socket): Opened socket connection
log (Logger): Logger object
exception: Exception type
Raises:
exception: Depends on input exception type
Returns:
str: Body of the received packet
"""
response_header = cls.receive_packet(conn, cls.HEADER_SIZE, log)
log.debug('Zabbix response header: %s', response_header)
if (not response_header.startswith(cls.ZABBIX_PROTOCOL) or
len(response_header) != cls.HEADER_SIZE):
log.debug('Unexpected response was received from Zabbix.')
raise exception('Unexpected response was received from Zabbix.')
flags, datalen, reserved = struct.unpack('<BII', response_header[4:])
# 0x01 - Zabbix communications protocol
if not flags & 0x01:
raise exception(
'Unexcepted flags were received. '
'Check debug log for more information.'
)
# 0x04 - Using large packet mode
if flags & 0x04:
raise exception(
'A large packet flag was received. '
'Current module doesn\'t support large packets.'
)
# 0x02 - Using packet compression mode
if flags & 0x02:
response_body = zlib.decompress(cls.receive_packet(conn, datalen, log))
else:
response_body = cls.receive_packet(conn, datalen, log)
return response_body.decode("utf-8")
@classmethod
async def parse_async_packet(cls, reader: asyncio.StreamReader, log: Logger, exception) -> str:
"""Parse a received asynchronously Zabbix protocol packet.
Args:
reader (StreamReader): Created asyncio.StreamReader
log (Logger): Logger object
exception: Exception type
Raises:
exception: Depends on input exception type
Returns:
str: Body of the received packet
"""
response_header = await reader.readexactly(cls.HEADER_SIZE)
log.debug('Zabbix response header: %s', response_header)
if (not response_header.startswith(cls.ZABBIX_PROTOCOL) or
len(response_header) != cls.HEADER_SIZE):
log.debug('Unexpected response was received from Zabbix.')
raise exception('Unexpected response was received from Zabbix.')
flags, datalen, reserved = struct.unpack('<BII', response_header[4:])
# 0x01 - Zabbix communications protocol
if not flags & 0x01:
raise exception(
'Unexcepted flags were received. '
'Check debug log for more information.'
)
# 0x04 - Using large packet mode
if flags & 0x04:
raise exception(
'A large packet flag was received. '
'Current module doesn\'t support large packets.'
)
# 0x02 - Using packet compression mode
if flags & 0x02:
response_body = zlib.decompress(await reader.readexactly(datalen))
else:
response_body = await reader.readexactly(datalen)
return response_body.decode("utf-8")

View File

@@ -0,0 +1,68 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
from typing import Union
from .common import ModuleUtils
class ModuleBaseException(Exception):
pass
class APIRequestError(ModuleBaseException):
"""Exception class when Zabbix API returns error by request.
Args:
api_error (str|dict): Raw error message from Zabbix API.
"""
def __init__(self, api_error: Union[str, dict]):
if isinstance(api_error, dict):
api_error['body'] = ModuleUtils.hide_private(api_error['body'])
super().__init__("{message} {data}".format(**api_error))
for key, value in api_error.items():
setattr(self, key, value)
else:
super().__init__(api_error)
class APINotSupported(ModuleBaseException):
"""Exception class when object/action is not supported by Zabbix API.
Args:
message (str): Not supported object/action message.
version (str): Current version of Zabbix API.
"""
def __init__(self, message: str, version: str = None):
if version:
message = f"{message} is unsupported for Zabbix {version} version"
super().__init__(message)
class ProcessingError(ModuleBaseException):
def __init__(self, *args):
super().__init__(" ".join(map(str, args)))
return

View File

@@ -0,0 +1,145 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import socket
import logging
from typing import Callable, Union
from .logger import EmptyHandler
from .types import AgentResponse
from .common import ZabbixProtocol
from .exceptions import ProcessingError
log = logging.getLogger(__name__)
log.addHandler(EmptyHandler())
class Getter():
"""Zabbix get synchronous implementation.
Args:
host (str, optional): Zabbix agent address. Defaults to `'127.0.0.1'`.
port (int, optional): Zabbix agent port. Defaults to `10050`.
timeout (int, optional): Connection timeout value. Defaults to `10`.
use_ipv6 (bool, optional): Specifying IPv6 use instead of IPv4. Defaults to `False`.
source_ip (str, optional): IP from which to establish connection. Defaults to `None`.
socket_wrapper (Callable, optional): Func(`conn`) to wrap socket. Defaults to `None`.
"""
def __init__(self, host: str = '127.0.0.1', port: int = 10050, timeout: int = 10,
use_ipv6: bool = False, source_ip: Union[str, None] = None,
socket_wrapper: Union[Callable, None] = None):
self.host = host
self.port = port
self.timeout = timeout
self.use_ipv6 = use_ipv6
self.source_ip = source_ip
self.socket_wrapper = socket_wrapper
if self.socket_wrapper:
if not isinstance(self.socket_wrapper, Callable):
raise TypeError('Value "socket_wrapper" should be a function.')
def __get_response(self, conn: socket) -> Union[str, None]:
result = ZabbixProtocol.parse_sync_packet(conn, log, ProcessingError)
log.debug('Received data: %s', result)
return result
def get(self, key: str) -> Union[str, None]:
"""Gets item value from Zabbix agent by specified key.
Args:
key (str): Zabbix item key.
Returns:
str: Value from Zabbix agent for specified key.
"""
packet = ZabbixProtocol.create_packet(key, log)
try:
if self.use_ipv6:
connection = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
else:
connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except socket.error:
raise ProcessingError(
f"Error creating socket for {self.host}:{self.port}") from None
connection.settimeout(self.timeout)
if self.source_ip:
connection.bind((self.source_ip, 0,))
try:
connection.connect((self.host, self.port))
if self.socket_wrapper is not None:
connection = self.socket_wrapper(connection)
connection.sendall(packet)
except (TimeoutError, socket.timeout) as err:
log.error(
'The connection to %s timed out after %d seconds',
f"{self.host}:{self.port}",
self.timeout
)
connection.close()
raise err
except (ConnectionRefusedError, socket.gaierror) as err:
log.error(
'An error occurred while trying to connect to %s: %s',
f"{self.host}:{self.port}",
getattr(err, 'msg', str(err))
)
connection.close()
raise err
except (OSError, socket.error) as err:
log.warning(
'An error occurred while trying to send to %s: %s',
f"{self.host}:{self.port}",
getattr(err, 'msg', str(err))
)
connection.close()
raise err
try:
response = self.__get_response(connection)
except ConnectionResetError as err:
log.debug('Get value error: %s', err)
log.warning('Check access restrictions in Zabbix agent configuration.')
raise err
log.debug('Response from [%s:%s]: %s', self.host, self.port, response)
try:
connection.close()
except socket.error:
pass
return AgentResponse(response)

View File

@@ -0,0 +1,53 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import json
import logging
from .common import ModuleUtils
class EmptyHandler(logging.Handler):
"""Empty logging handler."""
def emit(self, *args, **kwargs):
pass
class SensitiveFilter(logging.Filter):
"""Filter to hide sensitive Zabbix info (password, auth) in logs"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __hide_data(self, raw_data):
return json.dumps(ModuleUtils.hide_private(raw_data), indent=4, separators=(',', ': '))
def filter(self, record):
if isinstance(record.args, tuple):
record.args = tuple(self.__hide_data(arg)
if isinstance(arg, dict) else arg for arg in record.args)
if isinstance(record.args, dict):
record.args = self.__hide_data(record.args)
return 1

View File

@@ -0,0 +1,316 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import json
import socket
import logging
import configparser
from typing import Callable, Optional, Union, Tuple
from .logger import EmptyHandler
from .common import ZabbixProtocol
from .exceptions import ProcessingError
from .types import TrapperResponse, ItemValue, Cluster, Node
log = logging.getLogger(__name__)
log.addHandler(EmptyHandler())
class Sender():
"""Zabbix sender synchronous implementation.
Args:
server (str, optional): Zabbix server address. Defaults to `'127.0.0.1'`.
port (int, optional): Zabbix server port. Defaults to `10051`.
use_config (bool, optional): Specifying configuration use. Defaults to `False`.
timeout (int, optional): Connection timeout value. Defaults to `10`.
use_ipv6 (bool, optional): Specifying IPv6 use instead of IPv4. Defaults to `False`.
source_ip (str, optional): IP from which to establish connection. Defaults to `None`.
chunk_size (int, optional): Number of packets in one chunk. Defaults to `250`.
clusters (tuple|list, optional): List of Zabbix clusters. Defaults to `None`.
socket_wrapper (Callable, optional): Func(`conn`,`tls`) to wrap socket. Defaults to `None`.
compression (bool, optional): Specifying compression use. Defaults to `False`.
config_path (str, optional): Path to Zabbix agent configuration file. Defaults to \
`/etc/zabbix/zabbix_agentd.conf`.
"""
def __init__(self, server: Optional[str] = None, port: int = 10051,
use_config: bool = False, timeout: int = 10,
use_ipv6: bool = False, source_ip: Optional[str] = None,
chunk_size: int = 250, clusters: Union[tuple, list] = None,
socket_wrapper: Optional[Callable] = None, compression: bool = False,
config_path: Optional[str] = '/etc/zabbix/zabbix_agentd.conf'):
self.timeout = timeout
self.use_ipv6 = use_ipv6
self.tls = {}
self.host = None
self.source_ip = None
self.chunk_size = chunk_size
self.compression = compression
if socket_wrapper is not None:
if not isinstance(socket_wrapper, Callable):
raise TypeError('Value "socket_wrapper" should be a function.') from None
self.socket_wrapper = socket_wrapper
if source_ip is not None:
self.source_ip = source_ip
if use_config:
self.clusters = []
self.__load_config(config_path)
return
if clusters is not None:
if not (isinstance(clusters, tuple) or isinstance(clusters, list)):
raise TypeError('Value "clusters" should be a tuple or a list.') from None
clusters = clusters.copy()
if server is not None:
clusters.append([f"{server}:{port}"])
self.clusters = [Cluster(c) for c in clusters]
else:
self.clusters = [Cluster([f"{server or '127.0.0.1'}:{port}"])]
def __read_config(self, config: configparser.SectionProxy) -> None:
server_row = config.get('ServerActive') or config.get('Server') or '127.0.0.1:10051'
for cluster in server_row.split(','):
self.clusters.append(Cluster(cluster.strip().split(';')))
self.host = config.get('Hostname')
if 'SourceIP' in config:
self.source_ip = config.get('SourceIP')
for key in config:
if key.startswith('tls'):
self.tls[key] = config.get(key)
def __load_config(self, filepath: str) -> None:
config = configparser.ConfigParser(strict=False)
with open(filepath, 'r', encoding='utf-8') as cfg:
config.read_string('[root]\n' + cfg.read())
self.__read_config(config['root'])
def __get_response(self, conn: socket) -> Optional[dict]:
try:
result = json.loads(
ZabbixProtocol.parse_sync_packet(conn, log, ProcessingError)
)
except json.decoder.JSONDecodeError as err:
log.debug('Unexpected response was received from Zabbix.')
raise err
log.debug('Received data: %s', result)
return result
def __create_request(self, items: list) -> dict:
return {
"request": "sender data",
"data": [i.to_json() for i in items]
}
def __send_to_cluster(self, cluster: Cluster, packet: bytes) -> Optional[Tuple[Node, dict]]:
active_node = None
active_node_idx = 0
for i, node in enumerate(cluster.nodes):
log.debug('Trying to send data to %s', node)
try:
if self.use_ipv6:
connection = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
else:
connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except socket.error:
raise ProcessingError(f"Error creating socket for {node}") from None
connection.settimeout(self.timeout)
if self.source_ip:
connection.bind((self.source_ip, 0,))
try:
connection.connect((node.address, node.port))
except (TimeoutError, socket.timeout):
log.debug(
'The connection to %s timed out after %d seconds',
node,
self.timeout
)
except (ConnectionRefusedError, socket.gaierror) as err:
log.debug(
'An error occurred while trying to connect to %s: %s',
node,
getattr(err, 'msg', str(err))
)
else:
active_node_idx = i
if i > 0:
cluster.nodes[0], cluster.nodes[i] = cluster.nodes[i], cluster.nodes[0]
active_node_idx = 0
active_node = node
break
if active_node is None:
log.error(
'Couldn\'t connect to all of cluster nodes: %s',
str(list(cluster.nodes))
)
connection.close()
raise ProcessingError(
f"Couldn't connect to all of cluster nodes: {list(cluster.nodes)}"
)
if self.socket_wrapper is not None:
connection = self.socket_wrapper(connection, self.tls)
try:
connection.sendall(packet)
except (TimeoutError, socket.timeout) as err:
log.error(
'The connection to %s timed out after %d seconds while trying to send',
active_node,
self.timeout
)
connection.close()
raise err
except (OSError, socket.error) as err:
log.warning(
'An error occurred while trying to send to %s: %s',
active_node,
getattr(err, 'msg', str(err))
)
connection.close()
raise err
try:
response = self.__get_response(connection)
except ConnectionResetError as err:
log.debug('Get value error: %s', err)
raise err
log.debug('Response from %s: %s', active_node, response)
if response and response.get('response') != 'success':
if response.get('redirect'):
log.debug(
'Packet was redirected from %s to %s. Proxy group revision: %s.',
active_node,
response['redirect']['address'],
response['redirect']['revision']
)
cluster.nodes[active_node_idx] = Node(*response['redirect']['address'].split(':'))
active_node, response = self.__send_to_cluster(cluster, packet)
else:
raise socket.error(response)
try:
connection.close()
except socket.error:
pass
return active_node, response
def __chunk_send(self, items: list) -> dict:
responses = {}
packet = ZabbixProtocol.create_packet(self.__create_request(items), log, self.compression)
for cluster in self.clusters:
active_node, response = self.__send_to_cluster(cluster, packet)
responses[active_node] = response
return responses
def send(self, items: list) -> TrapperResponse:
"""Sends packets and receives an answer from Zabbix.
Args:
items (list): List of ItemValue objects.
Returns:
TrapperResponse: Response from Zabbix server/proxy.
"""
# Split the list of items into chunks of size self.chunk_size.
chunks = [items[i:i + self.chunk_size] for i in range(0, len(items), self.chunk_size)]
# Merge responses into a single TrapperResponse object.
try:
result = TrapperResponse()
except ProcessingError as err:
log.debug(err)
raise ProcessingError(err) from err
# TrapperResponse details for each node and chunk.
result.details = {}
for i, chunk in enumerate(chunks):
if not all(isinstance(item, ItemValue) for item in chunk):
log.debug('Received unexpected item list. It must be a list of \
ItemValue objects: %s', json.dumps(chunk))
raise ProcessingError(f"Received unexpected item list. \
It must be a list of ItemValue objects: {json.dumps(chunk)}")
resp_by_node = self.__chunk_send(chunk)
node_step = 1
for node, resp in resp_by_node.items():
try:
result.add(resp, (i + 1) * node_step)
except ProcessingError as err:
log.debug(err)
raise ProcessingError(err) from None
node_step += 1
if node not in result.details:
result.details[node] = []
result.details[node].append(TrapperResponse(i+1).add(resp))
return result
def send_value(self, host: str, key: str,
value: str, clock: Optional[int] = None,
ns: Optional[int] = None) -> TrapperResponse:
"""Sends one value and receives an answer from Zabbix.
Args:
host (str): Specify host name the item belongs to (as registered in Zabbix frontend).
key (str): Specify item key to send value to.
value (str): Specify item value.
clock (int, optional): Specify time in Unix timestamp format. Defaults to `None`.
ns (int, optional): Specify time expressed in nanoseconds. Defaults to `None`.
Returns:
TrapperResponse: Response from Zabbix server/proxy.
"""
return self.send([ItemValue(host or self.host or '', key, value, clock, ns)])

View File

@@ -0,0 +1,155 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import json
import socket
import logging
from typing import Callable, Union
from .logger import EmptyHandler
from .common import ZabbixProtocol
from .exceptions import ProcessingError
log = logging.getLogger(__name__)
log.addHandler(EmptyHandler())
class ZabbixStatus():
"""Zabbix server status receiver.
Args:
server (str, optional): Zabbix server address. Defaults to `'127.0.0.1'`.
port (int, optional): Zabbix server port. Defaults to `10051`.
timeout (int, optional): Connection timeout value. Defaults to `10`.
use_ipv6 (bool, optional): Specifying IPv6 use instead of IPv4. Defaults to `False`.
source_ip (str, optional): IP from which to establish connection. Defaults to `None`.
socket_wrapper (Callable, optional): Func(`conn`) to wrap socket. Defaults to `None`.
"""
def __init__(self, server: str = '127.0.0.1', port: int = 10051, timeout: int = 10,
use_ipv6: bool = False, source_ip: Union[str, None] = None,
socket_wrapper: Union[Callable, None] = None):
self.server = server
self.port = port
self.timeout = timeout
self.use_ipv6 = use_ipv6
self.source_ip = source_ip
self.socket_wrapper = socket_wrapper
if self.socket_wrapper:
if not isinstance(self.socket_wrapper, Callable):
raise TypeError('Value "socket_wrapper" should be a function.')
def __get_response(self, conn: socket) -> Union[str, None]:
result = ZabbixProtocol.parse_sync_packet(conn, log, ProcessingError)
log.debug('Received data: %s', result)
return result
def __create_request(self, sid: str) -> dict:
return {
"request": "status.get",
"type": "full",
"sid": sid,
}
def status(self, sid: str) -> Union[str, None]:
"""Gets status metrics from Zabbix server.
Args:
sid (str): Zabbix API session id.
Returns:
str: Zabbix server statistics data in JSON.
"""
packet = ZabbixProtocol.create_packet(self.__create_request(sid), log)
try:
if self.use_ipv6:
connection = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
else:
connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except socket.error:
raise ProcessingError(
f"Error creating socket for {self.server}:{self.port}") from None
connection.settimeout(self.timeout)
if self.source_ip:
connection.bind((self.source_ip, 0,))
try:
connection.connect((self.server, self.port))
if self.socket_wrapper is not None:
connection = self.socket_wrapper(connection)
connection.sendall(packet)
except (TimeoutError, socket.timeout) as err:
log.error(
'The connection to %s timed out after %d seconds',
f"{self.server}:{self.port}",
self.timeout
)
connection.close()
raise err
except (ConnectionRefusedError, socket.gaierror) as err:
log.error(
'An error occurred while trying to connect to %s: %s',
f"{self.server}:{self.port}",
getattr(err, 'msg', str(err))
)
connection.close()
raise err
except (OSError, socket.error) as err:
log.warning(
'An error occurred while trying to send to %s: %s',
f"{self.server}:{self.port}",
getattr(err, 'msg', str(err))
)
connection.close()
raise err
try:
response = json.loads(self.__get_response(connection))
except ConnectionResetError as err:
log.debug('Get value error: %s', err)
log.warning('Check access restrictions in Zabbix agent configuration.')
raise err
except json.decoder.JSONDecodeError as err:
log.debug('Parsing value error: %s', err)
raise err
log.debug('Response from [%s:%s]: %s', self.server, self.port, response)
try:
connection.close()
except socket.error:
pass
return response

View File

@@ -0,0 +1,401 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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.
import re
import json
from typing import Union, Any, List
from decimal import Decimal
from .exceptions import ProcessingError
from .version import __max_supported__
class APIVersion():
"""Zabbix API version object.
Args:
apiver (str): Raw version in string format.
"""
def __init__(self, apiver: str):
self.__raw = apiver
self.__first, self.__second, self.__third = self.__parse_version(self.__raw)
def __getitem__(self, index: int) -> Any:
# Get a symbol from the raw version string by index
# For compatibility with using Zabbix version as a string
return self.__raw[index]
def is_lts(self) -> bool:
"""Check if the current version is LTS.
Returns:
bool: `True` if the current version is LTS.
"""
return self.__second == 0
@property
def major(self) -> float:
"""Get major version number.
Returns:
float: A major version number.
"""
return float(f"{self.__first}.{self.__second}")
@property
def minor(self) -> int:
"""Get minor version number.
Returns:
int: A minor version number.
"""
return self.__third
def __parse_version(self, ver: str) -> List[Any]:
# Parse the version string into a list of integers.
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)', ver)
if match is None:
raise ValueError(
f"Unable to parse version of Zabbix API: {ver}. " +
f"Default '{__max_supported__}.0' format is expected."
) from None
return list(map(int, match.groups()))
def __str__(self) -> str:
return self.__raw
def __repr__(self) -> str:
return self.__raw
def __eq__(self, other: Union[float, str]) -> bool:
if isinstance(other, float):
return self.major == other
if isinstance(other, str):
return [self.__first, self.__second, self.__third] == self.__parse_version(other)
raise TypeError(
f"'==' not supported between instances of '{type(self).__name__}' and \
'{type(other).__name__}', only 'float' or 'str' is expected"
)
def __gt__(self, other: Union[float, str]) -> bool:
if isinstance(other, float):
return self.major > other
if isinstance(other, str):
return [self.__first, self.__second, self.__third] > self.__parse_version(other)
raise TypeError(
f"'>' not supported between instances of '{type(self).__name__}' and \
'{type(other).__name__}', only 'float' or 'str' is expected"
)
def __lt__(self, other: Union[float, str]) -> bool:
if isinstance(other, float):
return self.major < other
if isinstance(other, str):
return [self.__first, self.__second, self.__third] < self.__parse_version(other)
raise TypeError(
f"'<' not supported between instances of '{type(self).__name__}' and \
'{type(other).__name__}', only 'float' or 'str' is expected"
)
def __ne__(self, other: Any) -> bool:
return not self.__eq__(other)
def __ge__(self, other: Any) -> bool:
return not self.__lt__(other)
def __le__(self, other: Any) -> bool:
return not self.__gt__(other)
class TrapperResponse():
"""Contains response from Zabbix server/proxy.
Args:
chunk (int, optional): Current chunk number. Defaults to `1`.
"""
def __init__(self, chunk: int = 1):
self.__processed = 0
self.__failed = 0
self.__total = 0
self.__time = 0
self.__chunk = chunk
self.details = None
def __repr__(self) -> str:
result = {}
for key, value in self.__dict__.items():
if key == 'details':
continue
result[
key[len(f"_{self.__class__.__name__}__"):]
] = str(value) if isinstance(value, Decimal) else value
return json.dumps(result)
def parse(self, response: dict) -> dict:
"""Parse response from Zabbix.
Args:
response (dict): Raw response from Zabbix.
Raises:
ProcessingError: Raises if unexpected response received
"""
fields = {
"processed": ('[Pp]rocessed', r'\d+'),
"failed": ('[Ff]ailed', r'\d+'),
"total": ('[Tt]otal', r'\d+'),
"time": ('[Ss]econds spent', r'\d+\.\d+')
}
pattern = re.compile(
r";\s+?".join([rf"{r[0]}:\s+?(?P<{k}>{r[1]})" for k, r in fields.items()])
)
info = response.get('info')
if not info:
raise ProcessingError(f"Received unexpected response: {response}")
res = pattern.search(info).groupdict()
return res
def add(self, response: dict, chunk: Union[int, None] = None):
"""Add and merge response data from Zabbix.
Args:
response (dict): Raw response from Zabbix.
chunk (int, optional): Chunk number. Defaults to `None`.
"""
resp = self.parse(response)
def add_value(cls, key, value):
setattr(
cls,
key,
getattr(cls, key) + value
)
for k, v in resp.items():
add_value(
self,
f"_{self.__class__.__name__}__{k}",
Decimal(v) if '.' in v else int(v)
)
if chunk is not None:
self.__chunk = chunk
return self
@property
def processed(self) -> int:
"""Returns number of processed packets.
Returns:
int: Number of processed packets.
"""
return self.__processed
@property
def failed(self) -> int:
"""Returns number of failed packets.
Returns:
int: Number of failed packets.
"""
return self.__failed
@property
def total(self) -> int:
"""Returns total number of packets.
Returns:
int: Total number of packets.
"""
return self.__total
@property
def time(self) -> int:
"""Returns value of spent time.
Returns:
int: Spent time for the packets sending.
"""
return self.__time
@property
def chunk(self) -> int:
"""Returns current chunk number.
Returns:
int: Number of the current chunk.
"""
return self.__chunk
class ItemValue():
"""Contains data of a single item value.
Args:
host (str): Specify host name the item belongs to (as registered in Zabbix frontend).
key (str): Specify item key to send value to.
value (str): Specify item value.
clock (int, optional): Specify time in Unix timestamp format. Defaults to `None`.
ns (int, optional): Specify time expressed in nanoseconds. Defaults to `None`.
"""
def __init__(self, host: str, key: str, value: str,
clock: Union[int, None] = None, ns: Union[int, None] = None):
self.host = str(host)
self.key = str(key)
self.value = str(value)
self.clock = None
self.ns = None
if clock is not None:
try:
self.clock = int(clock)
except ValueError:
raise ValueError(
'The clock value must be expressed in the Unix Timestamp format') from None
if ns is not None:
try:
self.ns = int(ns)
except ValueError:
raise ValueError(
'The ns value must be expressed in the integer value of nanoseconds') from None
def __str__(self) -> str:
return json.dumps(self.to_json(), ensure_ascii=False)
def __repr__(self) -> str:
return self.__str__()
def to_json(self) -> dict:
"""Represents ItemValue object in dictionary for json.
Returns:
dict: Object attributes in dictionary.
"""
return {k: v for k, v in self.__dict__.items() if v is not None}
class Node():
"""Contains one Zabbix node object.
Args:
addr (str): Listen address of Zabbix server.
port (int, str): Listen port of Zabbix server.
Raises:
TypeError: Raises if not integer value was received.
"""
def __init__(self, addr: str, port: Union[int, str]):
self.address = addr if addr != '0.0.0.0/0' else '127.0.0.1'
try:
self.port = int(port)
except ValueError:
raise TypeError('Port must be an integer value') from None
def __str__(self) -> str:
return f"{self.address}:{self.port}"
def __repr__(self) -> str:
return self.__str__()
class Cluster():
"""Contains Zabbix node objects in a cluster object.
Args:
addr (list): Raw list of node addresses.
"""
def __init__(self, addr: list):
self.nodes = self.__parse_ha_node(addr)
def __parse_ha_node(self, node_list: list) -> list:
nodes = []
for node_item in node_list:
node_item = node_item.strip()
if ':' in node_item:
nodes.append(Node(*node_item.split(':')))
else:
nodes.append(Node(node_item, '10051'))
return nodes
def __str__(self) -> str:
return json.dumps([(node.address, node.port) for node in self.nodes])
def __repr__(self) -> str:
return self.__str__()
class AgentResponse:
"""Contains response from Zabbix agent/agent2.
Args:
response (string): Raw response from Zabbix.
"""
def __init__(self, response: str):
error_code = 'ZBX_NOTSUPPORTED'
self.raw = response
if response == error_code:
self.value = None
self.error = 'Not supported by Zabbix Agent'
elif response.startswith(error_code + '\0'):
self.value = None
self.error = response[len(error_code)+1:]
else:
idx = response.find('\0')
if idx == -1:
self.value = response
else:
self.value = response[:idx]
self.error = None
def __repr__(self) -> str:
return json.dumps({
'error': self.error,
'raw': self.raw,
'value': self.value,
})

View File

@@ -0,0 +1,28 @@
# zabbix_utils
#
# Copyright (C) 2001-2023 Zabbix SIA
#
# 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__ = "2.0.3"
__min_supported__ = 6.0
__max_supported__ = 7.4