Files
Yodmon/app/zabbix.py
Christoph Gasser ecafc5a48a Remove players deleted from Yodeck instead of showing them as offline
Poll loop now deletes DB rows for players no longer returned by the
Yodeck API, and Zabbix sync deletes the corresponding hosts from the
Yodeck Players group. Both actions are reflected in the activity log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:02:03 +02:00

291 lines
12 KiB
Python

"""
Zabbix API client for automatic host management.
Each Yodeck player becomes a Zabbix host with:
- hostname : yodeck-<yodeck_id>
- 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()