From 9fc3e975461233bccaaa78379474f66210687822 Mon Sep 17 00:00:00 2001 From: Christoph Gasser Date: Fri, 17 Apr 2026 09:31:00 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Yodmon=20Yodeck=E2=86=92Zab?= =?UTF-8?q?bix=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Yodeck API poller (every 10 min, paginated, 310 players) - SQLite persistence (players + activity logs) - SNMP v2c agent via net-snmp pass_persist - Zabbix API auto host creation/update (6.0+) - Flask web dashboard with live player status and log - Docker deployment with persistent volume - dev_server.py for local testing without Docker Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 23 ++++++ CLAUDE.md | 72 ++++++++++++++++++ Dockerfile | 23 ++++++ app/__init__.py | 0 app/config.py | 26 +++++++ app/database.py | 107 ++++++++++++++++++++++++++ app/scheduler.py | 40 ++++++++++ app/web.py | 32 ++++++++ app/yodeck.py | 25 ++++++ app/zabbix.py | 176 +++++++++++++++++++++++++++++++++++++++++++ dev_server.py | 51 +++++++++++++ docker-compose.yml | 39 ++++++++++ entrypoint.sh | 29 +++++++ main.py | 19 +++++ requirements.txt | 3 + snmp/pass_persist.py | 137 +++++++++++++++++++++++++++++++++ snmp_test.py | 75 ++++++++++++++++++ templates/index.html | 150 ++++++++++++++++++++++++++++++++++++ 18 files changed, 1027 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/scheduler.py create mode 100644 app/web.py create mode 100644 app/yodeck.py create mode 100644 app/zabbix.py create mode 100644 dev_server.py create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 snmp/pass_persist.py create mode 100644 snmp_test.py create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55dd664 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ + +# Database (persistent data — not versioned) +data/ + +# Environment / secrets +.env +.env.local + +# IDE +.vscode/ +.idea/ + +# Claude Code internal +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1e6ff88 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this project does + +Yodmon is a bridge between the **Yodeck** digital signage API and **Zabbix** monitoring. It polls the Yodeck API every 10 minutes, stores player state in SQLite, exposes the data via SNMP v2c (for Zabbix to poll every minute), auto-creates/updates Zabbix hosts, and provides a web dashboard. + +## Running locally (dev) + +```bash +pip install flask requests apscheduler +python dev_server.py # web UI at http://localhost:8080 +python snmp_test.py # print OID tree to verify SNMP data +python snmp_test.py --filter +``` + +`dev_server.py` sets `DB_PATH` to `./data/yodmon.db` and skips SNMP (Linux/Docker only). + +## Running in Docker + +```bash +docker compose up -d # SNMP on :161/udp, web UI on :8080 +``` + +Before starting, set `APP_HOST` in `docker-compose.yml` to the host IP reachable by Zabbix, and optionally fill in `ZABBIX_URL` / `ZABBIX_PASSWORD`. + +## Configuration + +All settings come from environment variables (see `app/config.py`). Key ones: + +| Variable | Default | Purpose | +|---|---|---| +| `YODECK_API_TOKEN` | — | `Token yodeck:` format | +| `APP_HOST` | `127.0.0.1` | IP Zabbix uses to reach this app's SNMP agent | +| `ENTERPRISE_OID` | `.1.3.6.1.4.1.99999` | Root OID for all player data | +| `SNMP_COMMUNITY` | `public` | SNMPv2c community string | +| `ZABBIX_URL` | `""` | Leave empty to disable Zabbix auto-sync | +| `DB_PATH` | `/data/yodmon.db` | SQLite file path | + +## Architecture + +``` +Yodeck API (every 10 min) + └── app/scheduler.py → app/yodeck.py fetches all screens (paginated) + → app/database.py upserts players + writes logs + → app/zabbix.py creates/updates Zabbix hosts via JSON-RPC API + +Zabbix SNMP poll (every 1 min) + └── snmpd (net-snmp) → snmp/pass_persist.py reads SQLite, serves OIDs over stdin/stdout + +Web UI + └── app/web.py (Flask) → templates/index.html shows player table + activity log +``` + +**SNMP OID structure** — `ENTERPRISE_OID.1.1..`: +- col 1 `STRING` hostname (`yodeck#`) +- col 2 `STRING` display name +- col 3 `INTEGER` online (0/1) +- col 4 `STRING` last_seen (ISO-8601) +- col 5 `INTEGER` updating (0/1) +- col 6 `INTEGER` registered (0/1) + +The Yodeck ID is used directly as the SNMP table index, so OIDs are stable and predictable (e.g. `.1.3.6.1.4.1.99999.1.1.3.54239` = online status of player 54239). + +**Database** — two tables in `yodmon.db`: +- `players` — one row per Yodeck player, upserted on each poll +- `logs` — append-only activity log; event types: `yodeck_fetch`, `zabbix_sync`, `snmp_transfer`, `error` + +**Docker entrypoint** (`entrypoint.sh`) templates `/etc/snmp/snmpd.conf` from env vars at startup, then starts `snmpd` in the background before handing off to `python main.py`. + +**Zabbix integration** (`app/zabbix.py`) targets Zabbix 6.0+ API (`item type 20` = SNMP agent). Set `ZABBIX_URL` to enable; the sync runs after every successful Yodeck poll. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f45b58 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +# Install net-snmp daemon +RUN apt-get update && apt-get install -y --no-install-recommends \ + snmpd \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chmod +x /app/snmp/pass_persist.py /app/entrypoint.sh + +# Persistent data lives here (mount a volume to this path) +VOLUME ["/data"] + +EXPOSE 161/udp +EXPOSE 8080 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..75f04c6 --- /dev/null +++ b/app/config.py @@ -0,0 +1,26 @@ +import os + +# Yodeck API +YODECK_API_TOKEN = os.environ.get('YODECK_API_TOKEN', '') +YODECK_API_BASE = 'https://app.yodeck.com/api/v2' +YODECK_POLL_INTERVAL_MINUTES = int(os.environ.get('YODECK_POLL_INTERVAL_MINUTES', '10')) + +# Zabbix (all optional — leave ZABBIX_URL empty to disable) +ZABBIX_URL = os.environ.get('ZABBIX_URL', '') +ZABBIX_USER = os.environ.get('ZABBIX_USER', 'Admin') +ZABBIX_PASSWORD = os.environ.get('ZABBIX_PASSWORD', '') +ZABBIX_HOST_GROUP = os.environ.get('ZABBIX_HOST_GROUP', 'Yodeck Players') +ZABBIX_SNMP_COMMUNITY = os.environ.get('ZABBIX_SNMP_COMMUNITY', 'public') +# IP/hostname of this app reachable by the Zabbix server for SNMP polling +APP_HOST = os.environ.get('APP_HOST', '127.0.0.1') + +# SNMP +SNMP_COMMUNITY = os.environ.get('SNMP_COMMUNITY', 'public') +# Private Enterprise Number OID used for all player data +ENTERPRISE_OID = os.environ.get('ENTERPRISE_OID', '.1.3.6.1.4.1.99999') + +# Database +DB_PATH = os.environ.get('DB_PATH', '/data/yodmon.db') + +# Web UI +WEB_PORT = int(os.environ.get('WEB_PORT', '8080')) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..b91803b --- /dev/null +++ b/app/database.py @@ -0,0 +1,107 @@ +import os +import sqlite3 +import json +from datetime import datetime, timezone +from app.config import DB_PATH + + +def _conn(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True) + conn = _conn() + conn.executescript(''' + CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + online INTEGER NOT NULL DEFAULT 0, + last_seen TEXT, + updating INTEGER NOT NULL DEFAULT 0, + registered INTEGER NOT NULL DEFAULT 0, + workspace_name TEXT, + player_type TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + event_type TEXT NOT NULL, + message TEXT NOT NULL, + details TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(timestamp DESC); + ''') + conn.commit() + conn.close() + + +def upsert_player(p): + state = p.get('state', {}) + workspace = p.get('workspace', {}) + now = datetime.now(timezone.utc).isoformat() + conn = _conn() + conn.execute(''' + INSERT INTO players (id, name, online, last_seen, updating, registered, + workspace_name, player_type, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + online = excluded.online, + last_seen = excluded.last_seen, + updating = excluded.updating, + registered = excluded.registered, + workspace_name = excluded.workspace_name, + player_type = excluded.player_type, + updated_at = excluded.updated_at + ''', ( + p['id'], + p['name'], + 1 if state.get('online') else 0, + state.get('last_seen'), + 1 if state.get('updating') else 0, + 1 if state.get('registered') else 0, + workspace.get('name'), + p.get('player_type'), + now, + )) + conn.commit() + conn.close() + + +def get_all_players(): + conn = _conn() + rows = conn.execute('SELECT * FROM players ORDER BY name').fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_player_counts(): + conn = _conn() + total = conn.execute('SELECT COUNT(*) FROM players').fetchone()[0] + online = conn.execute('SELECT COUNT(*) FROM players WHERE online = 1').fetchone()[0] + conn.close() + return total, online + + +def add_log(event_type, message, details=None): + now = datetime.now(timezone.utc).isoformat() + conn = _conn() + conn.execute( + 'INSERT INTO logs (timestamp, event_type, message, details) VALUES (?, ?, ?, ?)', + (now, event_type, message, json.dumps(details) if details else None), + ) + conn.commit() + conn.close() + + +def get_recent_logs(limit=200): + conn = _conn() + rows = conn.execute('SELECT * FROM logs ORDER BY id DESC LIMIT ?', (limit,)).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..3ad49ea --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,40 @@ +import logging +from apscheduler.schedulers.background import BackgroundScheduler +from app import database as db +from app import yodeck as yd +from app.zabbix import sync_to_zabbix +from app.config import YODECK_POLL_INTERVAL_MINUTES + +log = logging.getLogger(__name__) + + +def poll_yodeck(): + log.info("Polling Yodeck API …") + try: + players = yd.get_all_screens() + for p in players: + db.upsert_player(p) + msg = f"Fetched {len(players)} players from Yodeck API" + db.add_log('yodeck_fetch', msg, {'count': len(players)}) + log.info(msg) + # Sync host list to Zabbix after a successful fetch + sync_to_zabbix(players, db.add_log) + except Exception as exc: + msg = f"Yodeck poll failed: {exc}" + db.add_log('error', msg) + log.error(msg) + + +def start_scheduler(): + scheduler = BackgroundScheduler(daemon=True) + scheduler.add_job( + poll_yodeck, + 'interval', + minutes=YODECK_POLL_INTERVAL_MINUTES, + id='yodeck_poll', + ) + scheduler.start() + log.info("Scheduler started — Yodeck poll every %d minutes", YODECK_POLL_INTERVAL_MINUTES) + # Run an initial poll immediately so the DB is populated at startup + poll_yodeck() + return scheduler diff --git a/app/web.py b/app/web.py new file mode 100644 index 0000000..42b2bdf --- /dev/null +++ b/app/web.py @@ -0,0 +1,32 @@ +from flask import Flask, jsonify, render_template +from app import database as db + + +def create_app(): + app = Flask(__name__, template_folder='../templates') + + @app.route('/') + def index(): + total, online = db.get_player_counts() + return render_template( + 'index.html', + total=total, + online=online, + players=db.get_all_players(), + logs=db.get_recent_logs(200), + ) + + @app.route('/api/stats') + def api_stats(): + total, online = db.get_player_counts() + return jsonify({'total': total, 'online': online, 'offline': total - online}) + + @app.route('/api/players') + def api_players(): + return jsonify(db.get_all_players()) + + @app.route('/api/logs') + def api_logs(): + return jsonify(db.get_recent_logs(100)) + + return app diff --git a/app/yodeck.py b/app/yodeck.py new file mode 100644 index 0000000..5157930 --- /dev/null +++ b/app/yodeck.py @@ -0,0 +1,25 @@ +import logging +import requests +from app.config import YODECK_API_TOKEN, YODECK_API_BASE + +log = logging.getLogger(__name__) + + +def get_all_screens(): + """Fetch all screens/players from the Yodeck API (handles pagination).""" + if not YODECK_API_TOKEN: + raise RuntimeError("YODECK_API_TOKEN is not configured") + + headers = {'Authorization': f'Token {YODECK_API_TOKEN}'} + players = [] + url = f'{YODECK_API_BASE}/screens/' + + while url: + log.debug("GET %s", url) + resp = requests.get(url, headers=headers, timeout=30) + resp.raise_for_status() + data = resp.json() + players.extend(data.get('results', [])) + url = data.get('next') + + return players diff --git a/app/zabbix.py b/app/zabbix.py new file mode 100644 index 0000000..0e0add4 --- /dev/null +++ b/app/zabbix.py @@ -0,0 +1,176 @@ +""" +Zabbix API client for automatic host management. + +Each Yodeck player becomes a Zabbix host with: + - hostname : yodeck# + - visible name: the Yodeck player's display name + - 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_USER, ZABBIX_PASSWORD, + ZABBIX_HOST_GROUP, 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} + if self._auth: + payload['auth'] = self._auth + resp = requests.post(self._url, json=payload, 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): + self._auth = self._call('user.login', {'user': ZABBIX_USER, 'password': ZABBIX_PASSWORD}) + + def logout(self): + 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'] + return self._call('hostgroup.create', {'name': name})['groupids'][0] + + # ------------------------------------------------------------------ hosts + + def get_hosts_in_group(self, groupid): + return self._call('host.get', { + 'groupids': groupid, + 'output': ['hostid', 'host', 'name'], + }) + + 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}) + + # ------------------------------------------------------------------ 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.""" + 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) + existing = {h['host']: h for h in zbx.get_hosts_in_group(groupid)} + + created = updated = 0 + for player in players: + yid = player['id'] + hostname = f"yodeck#{yid}" + visible = player['name'] + + if hostname not in existing: + hostid = zbx.create_host(hostname, visible, groupid) + zbx.add_player_items(hostid, yid) + created += 1 + log.info("Created Zabbix host: %s (%s)", hostname, visible) + else: + if existing[hostname]['name'] != visible: + zbx.update_host_name(existing[hostname]['hostid'], visible) + updated += 1 + + msg = f"Zabbix sync complete: {created} created, {updated} updated ({len(players)} total)" + add_log_fn('zabbix_sync', msg, {'created': created, 'updated': updated, '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() diff --git a/dev_server.py b/dev_server.py new file mode 100644 index 0000000..5af557e --- /dev/null +++ b/dev_server.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Local development server for Yodmon. + +Runs the web UI and Yodeck polling without Docker or snmpd. +SNMP is not available in this mode — use Docker for full testing. + +Usage: + pip install flask requests apscheduler + python dev_server.py + Open http://localhost:8080 +""" +import os +import logging + +_HERE = os.path.dirname(os.path.abspath(__file__)) + +# Override paths/settings before importing app modules. +# All of these can also be set as real environment variables. +os.environ.setdefault('DB_PATH', os.path.join(_HERE, 'data', 'yodmon.db')) +os.environ.setdefault('YODECK_API_TOKEN', 'yodeck:fXQKm1hLvJY88necL3GiLVntpmyNS5BpKp8MpDK8GH2UvCPrg8BeHwpBSQaEtF0q') +os.environ.setdefault('WEB_PORT', '8080') + +# Zabbix sync is optional — leave ZABBIX_URL empty to skip it +os.environ.setdefault('ZABBIX_URL', '') + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(name)s: %(message)s', +) + +from app.config import DB_PATH, WEB_PORT +from app.database import init_db +from app.scheduler import start_scheduler +from app.web import create_app + +if __name__ == '__main__': + print("=" * 60) + print(" Yodmon — dev server") + print(f" DB : {DB_PATH}") + print(f" URL : http://localhost:{WEB_PORT}") + print(" SNMP: not available (Linux/Docker only)") + print("=" * 60) + print() + + os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True) + init_db() + start_scheduler() + + app = create_app() + app.run(host='127.0.0.1', port=WEB_PORT, use_reloader=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1cf2588 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + yodmon: + build: . + container_name: yodmon + restart: unless-stopped + ports: + - "161:161/udp" # SNMP — polled by Zabbix every minute + - "8080:8080" # Web UI + volumes: + - yodmon_data:/data + environment: + # ── Yodeck ──────────────────────────────────────────────────────────── + YODECK_API_TOKEN: "yodeck:fXQKm1hLvJY88necL3GiLVntpmyNS5BpKp8MpDK8GH2UvCPrg8BeHwpBSQaEtF0q" + YODECK_POLL_INTERVAL_MINUTES: "10" + + # ── SNMP ────────────────────────────────────────────────────────────── + SNMP_COMMUNITY: "public" + # Private Enterprise Number OID for this application. + # All player data is served under this subtree. + ENTERPRISE_OID: ".1.3.6.1.4.1.99999" + + # ── Network ─────────────────────────────────────────────────────────── + # IP address (or hostname) of THIS host, reachable by the Zabbix server. + # Zabbix will send SNMP polls to this address on port 161. + APP_HOST: "192.168.1.100" # ← change to your actual host IP + + # ── Zabbix API (optional) ───────────────────────────────────────────── + # Leave ZABBIX_URL empty to disable automatic host management. + # Requires Zabbix 6.0+. + ZABBIX_URL: "" # e.g. "http://zabbix.example.com" + ZABBIX_USER: "Admin" + ZABBIX_PASSWORD: "" + ZABBIX_HOST_GROUP: "Yodeck Players" + ZABBIX_SNMP_COMMUNITY: "public" + +volumes: + yodmon_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..cbc4e3c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +COMMUNITY="${SNMP_COMMUNITY:-public}" +ENT_OID="${ENTERPRISE_OID:-.1.3.6.1.4.1.99999}" + +# Write snmpd.conf from environment variables so community/OID are configurable +cat > /etc/snmp/snmpd.conf <=3.0.0 +requests>=2.31.0 +apscheduler>=3.10.4 diff --git a/snmp/pass_persist.py b/snmp/pass_persist.py new file mode 100644 index 0000000..ef0fa4f --- /dev/null +++ b/snmp/pass_persist.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +net-snmp pass_persist handler for Yodmon. + +Reads player data from the SQLite database and serves it over SNMP v2c. + +OID layout (base = ENTERPRISE_OID.1.1): + base.1. STRING "yodeck#" + base.2. STRING player display name + base.3. INTEGER online (1 = online, 0 = offline) + base.4. STRING last_seen (ISO-8601 timestamp) + base.5. INTEGER updating (1 = yes, 0 = no) + base.6. INTEGER registered (1 = yes, 0 = no) + +pass_persist protocol (net-snmp): + stdin: PING | get\n | getnext\n + stdout: PONG | \n\n | NONE +""" +import os +import sys +import sqlite3 +from datetime import datetime, timezone + +DB_PATH = os.environ.get('DB_PATH', '/data/yodmon.db') +ENTERPRISE_OID = os.environ.get('ENTERPRISE_OID', '.1.3.6.1.4.1.99999') +BASE_OID = f"{ENTERPRISE_OID}.1.1" + +# (column_index, snmp_type, value_extractor) +COLUMNS = [ + (1, 'STRING', lambda p: f"yodeck#{p['id']}"), + (2, 'STRING', lambda p: p.get('name') or ''), + (3, 'INTEGER', lambda p: str(p.get('online', 0))), + (4, 'STRING', lambda p: p.get('last_seen') or ''), + (5, 'INTEGER', lambda p: str(p.get('updating', 0))), + (6, 'INTEGER', lambda p: str(p.get('registered', 0))), +] + +# Rate-limit SNMP transfer log entries (at most one per minute) +_last_log_ts = 0.0 + + +def _get_players(): + try: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + rows = conn.execute('SELECT * FROM players ORDER BY id').fetchall() + conn.close() + return [dict(r) for r in rows] + except Exception: + return [] + + +def _build_tree(): + """Return a sorted list of (oid_tuple, oid_str, snmp_type, value).""" + entries = [] + for player in _get_players(): + pid = player['id'] + for col, typ, getter in COLUMNS: + oid_str = f"{BASE_OID}.{col}.{pid}" + oid_tuple = tuple(int(x) for x in oid_str.lstrip('.').split('.')) + entries.append((oid_tuple, oid_str, typ, getter(player))) + entries.sort(key=lambda e: e[0]) + return entries + + +def _to_tuple(oid_str): + return tuple(int(x) for x in oid_str.lstrip('.').split('.')) + + +def _log_transfer(): + global _last_log_ts + import time + now = time.time() + if now - _last_log_ts < 60: + return + _last_log_ts = now + try: + conn = sqlite3.connect(DB_PATH) + conn.execute( + "INSERT INTO logs (timestamp, event_type, message) VALUES (?, ?, ?)", + (datetime.now(timezone.utc).isoformat(), 'snmp_transfer', 'SNMP data served to Zabbix'), + ) + conn.commit() + conn.close() + except Exception: + pass + + +def _reply(oid_str, typ, value): + _log_transfer() + sys.stdout.write(f"{oid_str}\n{typ}\n{value}\n") + + +def main(): + while True: + try: + line = sys.stdin.readline() + if not line: + break + cmd = line.strip() + + if cmd == 'PING': + sys.stdout.write('PONG\n') + sys.stdout.flush() + continue + + if cmd in ('get', 'getnext'): + raw_oid = sys.stdin.readline().strip() + tree = _build_tree() + + if cmd == 'get': + tup = _to_tuple(raw_oid) + match = next((e for e in tree if e[0] == tup), None) + if match: + _reply(match[1], match[2], match[3]) + else: + sys.stdout.write('NONE\n') + + else: # getnext + tup = _to_tuple(raw_oid) + nxt = next((e for e in tree if e[0] > tup), None) + if nxt: + _reply(nxt[1], nxt[2], nxt[3]) + else: + sys.stdout.write('NONE\n') + + sys.stdout.flush() + + except (EOFError, BrokenPipeError, KeyboardInterrupt): + break + except Exception as exc: + sys.stderr.write(f"pass_persist error: {exc}\n") + sys.stderr.flush() + + +if __name__ == '__main__': + main() diff --git a/snmp_test.py b/snmp_test.py new file mode 100644 index 0000000..cf85f38 --- /dev/null +++ b/snmp_test.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +Print the full SNMP OID tree that would be served by the SNMP agent. + +Useful for verifying player data and OID mapping without running snmpd. +Run this after dev_server.py has done at least one Yodeck poll. + +Usage: + python snmp_test.py [--filter ] + +Examples: + python snmp_test.py # all players + python snmp_test.py --filter TRN_FRONT # only matching names + python snmp_test.py --filter 54239 # by Yodeck ID +""" +import os +import sys +import argparse + +_HERE = os.path.dirname(os.path.abspath(__file__)) +os.environ.setdefault('DB_PATH', os.path.join(_HERE, 'data', 'yodmon.db')) +os.environ.setdefault('ENTERPRISE_OID', '.1.3.6.1.4.1.99999') + +from app.config import DB_PATH, ENTERPRISE_OID +from app.database import get_all_players, get_player_counts + +COLUMNS = [ + (1, 'STRING', 'hostname', lambda p: f"yodeck#{p['id']}"), + (2, 'STRING', 'name', lambda p: p.get('name') or ''), + (3, 'INTEGER', 'online', lambda p: str(p.get('online', 0))), + (4, 'STRING', 'last_seen', lambda p: p.get('last_seen') or ''), + (5, 'INTEGER', 'updating', lambda p: str(p.get('updating', 0))), + (6, 'INTEGER', 'registered', lambda p: str(p.get('registered', 0))), +] + +def main(): + parser = argparse.ArgumentParser(description='Show Yodmon SNMP OID tree') + parser.add_argument('--filter', metavar='TEXT', default='', + help='Only show players whose name or ID contains TEXT') + args = parser.parse_args() + + players = get_all_players() + total, online = get_player_counts() + base = ENTERPRISE_OID.lstrip('.') + '.1.1' + + if args.filter: + players = [p for p in players + if args.filter.lower() in p['name'].lower() + or args.filter in str(p['id'])] + + print(f"DB : {DB_PATH}") + print(f"Enterprise OID : {ENTERPRISE_OID}") + print(f"Base OID : .{base}..") + print(f"Players : {total} total, {online} online") + if args.filter: + print(f"Filter : '{args.filter}' -> {len(players)} match(es)") + print() + + col_header = f"{'OID':<52} {'TYPE':<10} {'VALUE'}" + print(col_header) + print('-' * len(col_header)) + + for p in players: + pid = p['id'] + print(f" -- yodeck#{pid} ({p['name']})") + for col, typ, label, getter in COLUMNS: + oid = f".{base}.{col}.{pid}" + val = getter(p) + print(f" {oid:<50} {typ:<10} {val}") + print() + + print(f"Total OIDs: {len(players) * len(COLUMNS)}") + +if __name__ == '__main__': + main() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5c747f2 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,150 @@ + + + + + + Yodmon – Yodeck Monitor + + + + + + + + +
+ + +
+
+
+
{{ total }}
+
Total Players
+
+
+
+
+
{{ online }}
+
Online
+
+
+
+
+
{{ total - online }}
+
Offline
+
+
+
+
+
+ {% set last_fetch = logs | selectattr('event_type', 'equalto', 'yodeck_fetch') | first %} + {% if last_fetch %}{{ last_fetch.timestamp[:19] }} UTC{% else %}—{% endif %} +
+
Last API Fetch
+
+
+
+ + +
+
+ Players + {{ total }} +
+
+
+ + + + + + + + + + + + + + + {% for p in players %} + + + + + + + + + + + {% endfor %} + +
Yodeck IDNameWorkspaceTypeStatusLast Seen (UTC)UpdatingRegistered
{{ p.id }}{{ p.name }}{{ p.workspace_name or '—' }}{{ p.player_type or '—' }} + {% if p.online %} + ● Online + {% else %} + ● Offline + {% endif %} + {{ (p.last_seen or '—')[:19] }}{% if p.updating %}Yes{% else %}No{% endif %}{% if p.registered %}{% else %}{% endif %}
+
+
+
+ + +
+
+ Activity Log + last 200 +
+
+
+ + + + + + + + + + {% for l in logs %} + + + + + + {% endfor %} + +
Timestamp (UTC)EventMessage
{{ l.timestamp[:19] }} + {{ l.event_type }} + {{ l.message }}
+
+
+
+ +
+ +
+ Page auto-refreshes every 60 seconds  |  + API: stats   + players   + logs +
+ + +