""" Zabbix API client for automatic host management. Each Yodeck player becomes a Zabbix host with: - hostname : yodeck- - visible name: "QRS-" + first 4 chars + "MB-" + remainder (e.g. AMS-COF1 -> QRS-AMS-MB-COF1) - SNMP v2c interface pointing to this app (APP_HOST:161) - Four SNMP items: online, last_seen, updating, registered """ import logging import requests from app.config import ( ZABBIX_URL, ZABBIX_API_TOKEN, ZABBIX_USER, ZABBIX_PASSWORD, ZABBIX_HOST_GROUP, ZABBIX_TEMPLATE, ZABBIX_SNMP_COMMUNITY, APP_HOST, ENTERPRISE_OID, ) log = logging.getLogger(__name__) SNMP_PORT = 161 # OID column indices (must match snmp/pass_persist.py) COL_HOSTNAME = 1 COL_NAME = 2 COL_ONLINE = 3 COL_LAST_SEEN = 4 COL_UPDATING = 5 COL_REGISTERED = 6 class ZabbixClient: def __init__(self): self._url = f"{ZABBIX_URL.rstrip('/')}/api_jsonrpc.php" self._auth = None self._id = 0 def _call(self, method, params): self._id += 1 payload = {'jsonrpc': '2.0', 'method': method, 'params': params, 'id': self._id} headers = {} if ZABBIX_API_TOKEN: # Zabbix 6.4+: API tokens go in the Authorization header, not the payload headers['Authorization'] = f'Bearer {ZABBIX_API_TOKEN}' elif self._auth: # Zabbix < 6.4 / user+password sessions: auth goes in the JSON-RPC payload payload['auth'] = self._auth resp = requests.post(self._url, json=payload, headers=headers, timeout=30) resp.raise_for_status() body = resp.json() if 'error' in body: raise RuntimeError(f"Zabbix [{method}]: {body['error'].get('data', body['error'])}") return body['result'] def login(self): if ZABBIX_API_TOKEN: pass # token is sent via Authorization header in every _call — no login needed else: self._auth = self._call('user.login', {'user': ZABBIX_USER, 'password': ZABBIX_PASSWORD}) def logout(self): if ZABBIX_API_TOKEN: return # API tokens have no session to invalidate try: self._call('user.logout', []) except Exception: pass self._auth = None # ------------------------------------------------------------------ groups def ensure_hostgroup(self, name): existing = self._call('hostgroup.get', {'filter': {'name': name}, 'output': ['groupid']}) if existing: return existing[0]['groupid'] try: return self._call('hostgroup.create', {'name': name})['groupids'][0] except RuntimeError as exc: if 'permission' in str(exc).lower(): raise RuntimeError( f"Host group '{name}' not found or not visible to the API token user. " f"Fix in Zabbix: Administration -> User groups -> [your group] -> " f"Permissions -> add '{name}' with Read-write access. " f"Alternatively assign Super admin role to the API token user." ) from exc raise # ------------------------------------------------------------------ hosts def get_hosts_in_group(self, groupid): return self._call('host.get', { 'groupids': groupid, 'output': ['hostid', 'host', 'name'], 'selectParentTemplates': ['templateid'], }) def create_host(self, hostname, visible_name, groupid): result = self._call('host.create', { 'host': hostname, 'name': visible_name, 'groups': [{'groupid': groupid}], 'interfaces': [{ 'type': 2, # SNMP 'main': 1, 'useip': 1, 'ip': APP_HOST, 'dns': '', 'port': str(SNMP_PORT), 'details': { 'version': 2, # SNMPv2c 'community': ZABBIX_SNMP_COMMUNITY, 'bulk': 1, }, }], }) return result['hostids'][0] def update_host_name(self, hostid, visible_name): self._call('host.update', {'hostid': hostid, 'name': visible_name}) def delete_hosts(self, hostids): self._call('host.delete', hostids) # ------------------------------------------------------------------ templates def get_template_id(self, name): # Search by technical name (host field) — case-insensitive search with exact-match fallback result = self._call('template.get', { 'output': ['templateid', 'host'], 'search': {'host': name}, 'searchByAny': True, }) # Exact match from search results for t in result: if t['host'] == name: return t['templateid'] # Log what IS visible to help diagnose permission issues if result: visible = [t['host'] for t in result] log.warning("Template '%s' not found; visible templates: %s", name, visible) else: all_tpl = self._call('template.get', {'output': ['host'], 'limit': 5}) log.warning( "Template '%s' not found and API token can see %d template(s) total (first 5: %s). " "Fix in Zabbix: Administration → User groups → [your group] → " "Template permissions → add 'Templates' group with Read access.", name, len(all_tpl), [t['host'] for t in all_tpl], ) return None def link_template(self, hostid, templateid): # host.update with templates replaces the list — fetch existing first to avoid unlinking others existing = self._call('host.get', { 'hostids': hostid, 'selectParentTemplates': ['templateid'], 'output': [], }) current = [{'templateid': t['templateid']} for t in existing[0].get('parentTemplates', [])] if not any(t['templateid'] == templateid for t in current): self._call('host.update', {'hostid': hostid, 'templates': current + [{'templateid': templateid}]}) def upsert_host_macro(self, hostid, macro, value): existing = self._call('usermacro.get', { 'hostids': hostid, 'filter': {'macro': macro}, 'output': ['hostmacroid'], }) if existing: self._call('usermacro.update', {'hostmacroid': existing[0]['hostmacroid'], 'value': value}) else: self._call('usermacro.create', {'hostid': hostid, 'macro': macro, 'value': value}) # ------------------------------------------------------------------ items def _get_interface_id(self, hostid): ifaces = self._call('hostinterface.get', { 'hostids': hostid, 'output': ['interfaceid'], }) return ifaces[0]['interfaceid'] if ifaces else None def _create_item(self, hostid, interfaceid, name, key, oid, value_type): """ type 20 = SNMP agent (Zabbix 6.0+) value_type 3 = unsigned int, 4 = text """ try: self._call('item.create', { 'hostid': hostid, 'interfaceid': interfaceid, 'name': name, 'key_': key, 'type': 20, 'snmp_oid': oid, 'value_type': value_type, 'delay': '1m', 'history': '90d', }) except Exception as exc: log.warning("Could not create item '%s' on host %s: %s", name, hostid, exc) def add_player_items(self, hostid, yodeck_id): ifid = self._get_interface_id(hostid) if not ifid: log.warning("No SNMP interface found for host %s", hostid) return base = f"{ENTERPRISE_OID}.1.1" self._create_item(hostid, ifid, 'Online', 'yodeck.online', f'{base}.{COL_ONLINE}.{yodeck_id}', value_type=3) self._create_item(hostid, ifid, 'Last Seen', 'yodeck.last_seen', f'{base}.{COL_LAST_SEEN}.{yodeck_id}', value_type=4) self._create_item(hostid, ifid, 'Updating', 'yodeck.updating', f'{base}.{COL_UPDATING}.{yodeck_id}', value_type=3) self._create_item(hostid, ifid, 'Registered', 'yodeck.registered', f'{base}.{COL_REGISTERED}.{yodeck_id}', value_type=3) def sync_to_zabbix(players, add_log_fn): """Sync player list to Zabbix: create missing hosts, update names, link template.""" if not ZABBIX_URL: log.debug("ZABBIX_URL not configured — skipping Zabbix sync") return zbx = ZabbixClient() try: zbx.login() groupid = zbx.ensure_hostgroup(ZABBIX_HOST_GROUP) template_id = zbx.get_template_id(ZABBIX_TEMPLATE) if ZABBIX_TEMPLATE else None if ZABBIX_TEMPLATE and not template_id: msg = f"Template '{ZABBIX_TEMPLATE}' not found in Zabbix — import zabbix_template.yaml first" log.warning(msg) add_log_fn('error', msg) existing = {h['host']: h for h in zbx.get_hosts_in_group(groupid)} created = updated = skipped = 0 for player in players: yid = player['id'] hostname = f"yodeck-{yid}" visible = f"QRS-{player['name'][:4]}MB-{player['name'][4:]}" try: if hostname not in existing: hostid = zbx.create_host(hostname, visible, groupid) zbx.upsert_host_macro(hostid, '{$YODECK_ID}', str(yid)) if template_id: zbx.link_template(hostid, template_id) else: zbx.add_player_items(hostid, yid) created += 1 log.info("Created Zabbix host: %s (%s)", hostname, visible) else: h = existing[hostname] changed = False if h['name'] != visible: zbx.update_host_name(h['hostid'], visible) changed = True if template_id: linked = {t['templateid'] for t in h.get('parentTemplates', [])} if template_id not in linked: zbx.upsert_host_macro(h['hostid'], '{$YODECK_ID}', str(yid)) zbx.link_template(h['hostid'], template_id) log.info("Linked template to existing host: %s", hostname) changed = True if changed: updated += 1 except Exception as exc: log.warning("Skipped host %s: %s", hostname, exc) skipped += 1 current_hostnames = {f"yodeck-{p['id']}" for p in players} stale = [h for h in existing.values() if h['host'] not in current_hostnames] deleted = 0 for h in stale: try: zbx.delete_hosts([h['hostid']]) deleted += 1 log.info("Deleted stale Zabbix host: %s", h['host']) except Exception as exc: log.warning("Could not delete host %s: %s", h['host'], exc) skipped += 1 msg = (f"Zabbix sync complete: {created} created, {updated} updated" + (f", {deleted} deleted" if deleted else "") + (f", {skipped} skipped" if skipped else "") + f" ({len(players)} total)") add_log_fn('zabbix_sync', msg, {'created': created, 'updated': updated, 'deleted': deleted, 'total': len(players)}) log.info(msg) except Exception as exc: msg = f"Zabbix sync failed: {exc}" add_log_fn('error', msg) log.error(msg) finally: zbx.logout()