survives image upgrades and is portable. */ namespace Modules\WecomCenter\Lib; use API; use Exception; class Helper { // Zabbix trigger severity index => media severity bit (1 << index). public const SEVERITY_BITS = [1, 2, 4, 8, 16, 32]; // Built-in alert schemes: name => [severity_mask, active(0=enabled,1=disabled)] public const SCHEMES = [ 'standard' => [48, 0], // High + Disaster (recommended) 'warnplus' => [56, 0], // Average + High + Disaster 'all' => [63, 0], // everything 'quiet' => [32, 0], // Disaster only 'off' => [63, 1] // alerting disabled ]; /* ------------------------------------------------------------------ */ /* Media types / robot */ /* ------------------------------------------------------------------ */ /** * All webhook media types (type == 4). */ public static function webhookMediatypes(): array { try { $res = API::Mediatype()->get([ 'output' => ['mediatypeid', 'name', 'type', 'status'], 'selectParameters' => 'extend', 'filter' => ['type' => 4] ]); return is_array($res) ? $res : []; } catch (\Throwable $e) { return []; } } public static function getMediatype(string $mediatypeid): ?array { try { $res = API::Mediatype()->get([ 'output' => ['mediatypeid', 'name', 'type', 'status'], 'selectParameters' => 'extend', 'mediatypeids' => [$mediatypeid] ]); return (is_array($res) && $res) ? $res[0] : null; } catch (\Throwable $e) { return null; } } // Fixed WeCom group-robot endpoint; the per-bot secret lives in {$WECOM_KEY}. public const WECOM_BASE = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key='; public const KEY_MACRO = '{$WECOM_KEY}'; /** Read a global macro value; null when missing. */ public static function getGlobalMacro(string $name): ?string { try { $res = API::UserMacro()->get([ 'globalmacro' => true, 'output' => ['globalmacroid', 'macro', 'value'], 'filter' => ['macro' => $name] ]); return ($res && isset($res[0])) ? (string) $res[0]['value'] : null; } catch (\Throwable $e) { return null; } } /** Create or update a global macro. */ public static function setGlobalMacro(string $name, string $value): void { $res = API::UserMacro()->get([ 'globalmacro' => true, 'output' => ['globalmacroid', 'macro'], 'filter' => ['macro' => $name] ]); if ($res && isset($res[0])) { API::UserMacro()->updateGlobal([ ['globalmacroid' => $res[0]['globalmacroid'], 'value' => $value] ]); } else { API::UserMacro()->createGlobal([ ['macro' => $name, 'value' => $value] ]); } } /** * Current robot key: {$WECOM_KEY} global macro, falling back to any legacy * value still stored in the media type parameters. */ public static function webhookKey(?array $mediatype = null): string { $v = self::getGlobalMacro(self::KEY_MACRO); if ($v !== null && $v !== '') { return $v; } foreach (($mediatype['parameters'] ?? []) as $p) { $n = strtolower($p['name']); $val = (string) $p['value']; if ($n === 'key' && strpos($val, '{') === false) { return $val; } if (in_array($n, ['webhook_url', 'url', 'webhook'], true)) { if (strpos($val, 'key=') !== false) { return substr($val, strpos($val, 'key=') + 4); } if (strpos($val, 'http') !== 0) { return $val; } } } return ''; } /** Persist the robot key into the {$WECOM_KEY} global macro (managed here, not in the media type). */ public static function setWebhookKey(string $key): void { self::setGlobalMacro(self::KEY_MACRO, $key); } /** Full webhook URL for this bot = fixed base + {$WECOM_KEY}. */ public static function webhookUrl(?array $mediatype = null): string { $key = self::webhookKey($mediatype); return $key === '' ? '' : self::WECOM_BASE.$key; } /* ------------------------------------------------------------------ */ /* Robot user + media (severity / active) */ /* ------------------------------------------------------------------ */ /** * Find the dedicated robot user with its media. */ public static function robotUser(string $username = 'wecomrobot'): ?array { $res = API::User()->get([ 'output' => ['userid', 'username'], 'selectMedias' => 'extend', 'filter' => ['username' => $username] ]); return $res ? $res[0] : null; } /** * Current severity mask + active flag of a user's media for a media type. * Returns ['active'=>int, 'severity'=>int] or null when media absent. */ public static function currentMedia(?array $user, string $mediatypeid): ?array { if (!$user || empty($user['medias'])) { return null; } foreach ($user['medias'] as $m) { if ((string) $m['mediatypeid'] === (string) $mediatypeid) { return [ 'active' => (int) $m['active'], 'severity' => (int) $m['severity'], 'period' => $m['period'] ?? '1-7,00:00-24:00', 'sendto' => $m['sendto'] ?? '' ]; } } return null; } /** * Ensure a robot user exists; create one (no frontend access) if missing. */ public static function ensureRobotUser(string $username): array { $user = self::robotUser($username); if ($user) { return $user; } // best-effort group / role for a non-login notification account $grp = API::UserGroup()->get([ 'output' => ['usrgrpid', 'name'], 'filter' => ['name' => 'No access to the frontend'], 'limit' => 1 ]); $usrgrpid = $grp ? $grp[0]['usrgrpid'] : null; if ($usrgrpid === null) { $all = API::UserGroup()->get(['output' => ['usrgrpid'], 'limit' => 1]); $usrgrpid = $all ? $all[0]['usrgrpid'] : null; } $role = API::Role()->get([ 'output' => ['roleid', 'name'], 'filter' => ['name' => 'User role'], 'limit' => 1 ]); $roleid = $role ? $role[0]['roleid'] : null; $create = [ 'username' => $username, 'passwd' => bin2hex(random_bytes(16)).'Aa1!', 'usrgrps' => [['usrgrpid' => $usrgrpid]] ]; if ($roleid !== null) { $create['roleid'] = $roleid; } $res = API::User()->create($create); return self::robotUser($username); } /** * Apply severity mask + active flag to a user's media for a media type. * Rebuilds the whole media list (API user.update replaces medias). */ public static function applyMedia(string $userid, string $mediatypeid, int $severity, int $active, string $sendto = 'WeCom') { $res = API::User()->get([ 'output' => ['userid'], 'selectMedias' => 'extend', 'userids' => [$userid] ]); $medias = []; if ($res) { foreach (($res[0]['medias'] ?? []) as $m) { $medias[] = [ 'mediatypeid' => $m['mediatypeid'], 'sendto' => $m['sendto'], 'severity' => (string) $m['severity'], 'active' => (string) $m['active'], 'period' => $m['period'] ?: '1-7,00:00-24:00' ]; } } $hit = false; foreach ($medias as &$m) { if ((string) $m['mediatypeid'] === (string) $mediatypeid) { $m['severity'] = (string) $severity; $m['active'] = (string) $active; if ($sendto !== '') { $m['sendto'] = $sendto; } $hit = true; } } unset($m); if (!$hit) { $medias[] = [ 'mediatypeid' => $mediatypeid, 'sendto' => $sendto !== '' ? $sendto : 'WeCom', 'severity' => (string) $severity, 'active' => (string) $active, 'period' => '1-7,00:00-24:00' ]; } API::User()->update(['userid' => $userid, 'medias' => $medias]); } /* ------------------------------------------------------------------ */ /* Send test message */ /* ------------------------------------------------------------------ */ public static function testWebhook(string $url, string $text): array { $payload = json_encode(['msgtype' => 'text', 'text' => ['content' => $text]], JSON_UNESCAPED_UNICODE); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0 ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); return ['status' => $status, 'body' => $body !== false ? $body : $err]; } /* ------------------------------------------------------------------ */ /* Templates */ /* ------------------------------------------------------------------ */ /** * Bundled templates shipped inside modules/WecomCenter/templates/. */ public static function bundledTemplates(string $module_dir): array { $dir = rtrim($module_dir, '/').'/templates'; $out = []; if (!is_dir($dir)) { return $out; } foreach (scandir($dir) as $f) { if ($f === '.' || $f === '..') { continue; } $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION)); if (in_array($ext, ['xml', 'yaml', 'yml', 'json'], true)) { $out[] = ['file' => $f, 'name' => pathinfo($f, PATHINFO_FILENAME)]; } } return $out; } public static function importTemplate(string $module_dir, string $file): array { $dir = rtrim($module_dir, '/').'/templates'; $file = basename($file); $path = $dir.'/'.$file; if (!is_file($path)) { throw new Exception('Template file not found: '.$file); } $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $format = ['xml' => 'xml', 'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml'][$ext] ?? 'yaml'; $source = file_get_contents($path); $result = API::Configuration()->import([ 'format' => $format, 'source' => $source, 'rules' => [ 'template_groups' => ['createMissing' => true, 'updateExisting' => true], 'templates' => ['createMissing' => true, 'updateExisting' => true], 'items' => ['createMissing' => true, 'updateExisting' => true], 'triggers' => ['createMissing' => true, 'updateExisting' => true] ] ]); return is_array($result) ? $result : []; } /* ------------------------------------------------------------------ */ /* Map coordinates */ /* ------------------------------------------------------------------ */ public static function hosts(): array { return API::Host()->get([ 'output' => ['hostid', 'name'], 'sortfield' => 'name', 'limit' => 1000 ]); } public static function setHostLocation(string $hostid, string $lat, string $lon): void { API::Host()->update([ 'hostid' => $hostid, 'inventory_mode' => 0, // manual 'inventory' => [ 'location_lat' => $lat, 'location_lon' => $lon ] ]); } public static function hostLocation(string $hostid): array { $res = API::Host()->get([ 'output' => ['hostid', 'name'], 'selectInventory' => ['location_lat', 'location_lon'], 'hostids' => [$hostid] ]); return $res ? $res[0] : []; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ public static function severityMask(array $indices): int { $mask = 0; foreach ($indices as $i) { $i = (int) $i; if ($i >= 1 && $i <= 6) { $mask |= self::SEVERITY_BITS[$i - 1]; } } return $mask; } /* ------------------------------------------------------------------ */ /* Triggers (per-trigger alerting switch) */ /* ------------------------------------------------------------------ */ /** * Search triggers by description. An empty search returns nothing on * purpose - the instance has tens of thousands of triggers, listing all * is pointless. Results are capped. */ public static function triggers(string $search, int $limit = 200): array { $search = trim($search); if ($search === '') { return []; } try { $res = API::Trigger()->get([ 'output' => ['triggerid', 'description', 'status', 'priority'], 'selectHosts' => ['host'], 'search' => ['description' => $search], 'limit' => $limit ]); return is_array($res) ? $res : []; } catch (\Throwable $e) { return []; } } public static function triggerCount(): int { try { $n = API::Trigger()->get(['countOutput' => true]); return (int) $n; } catch (\Throwable $e) { return 0; } } /** * Set trigger status. $map = [triggerid => status(0 enabled,1 disabled)]. * NB: this Zabbix version's trigger.update does NOT accept a "triggers" * array, so we update one trigger at a time. */ public static function setTriggersStatus(array $map): int { $changed = 0; foreach ($map as $tid => $status) { $res = API::Trigger()->update([ 'triggerid' => (string) $tid, 'status' => (string) ((int) $status) ]); if ($res === false) { throw new Exception('trigger.update failed for '.$tid); } $changed++; } return $changed; } /* ================================================================== */ /* ALERT STRATEGY ENGINE */ /* Classify triggers by name keyword, map to severity, apply to the */ /* template triggers + prototypes (API) and discovered triggers (SQL)*/ /* ================================================================== */ public const TEMPLATE_NAME = 'Huawei VRP by SNMP walk'; public const STRATEGY_MACRO = '{$WECOM_STRATEGY}'; // Ordered classes - first match wins (highest priority first). public const CLASSES = [ 'device_down' => [ 'label' => 'cls.device_down', 'kw' => ['unavailable by icmp', 'icmp ping loss', 'no snmp data', 'no data', 'unreachable'] ], 'hw_fault' => [ 'label' => 'cls.hw_fault', 'kw' => ['fan is in critical', 'power supply', 'temperature is above critical', 'temperature is too low', 'is in critical state', 'psu'] ], 'restarted' => [ 'label' => 'cls.restarted', 'kw' => ['has been restarted', 'has been rebooted'] ], 'core_link' => [ 'label' => 'cls.core_link', 'kw' => ['link down'], 'core' => ['to:', 'dist', 'core', 'csw', 'uplink', 'aggregat', '汇聚', 'trunk'] ], 'interface_down' => [ 'label' => 'cls.interface_down', 'kw' => ['link down'] ], 'perf' => [ 'label' => 'cls.perf', 'kw' => ['high bandwidth usage', 'high error rate', 'high cpu', 'high memory', 'utilization', 'high icmp ping response time', 'changed to lower speed', 'temperature is above warning'] ], 'other' => ['label' => 'cls.other', 'kw' => []] ]; // Selectable alert strategies. min = lowest severity index that still notifies. public const STRATEGY_PRESETS = [ 'steady' => [ 'label' => 'preset.steady', 'desc' => 'preset.steady.desc', 'min' => 4, 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5, 'interface_down' => 3, 'perf' => 2] ], 'core' => [ 'label' => 'preset.core', 'desc' => 'preset.core.desc', 'min' => 5, 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5, 'interface_down' => 2, 'perf' => 1] ], 'observe' => [ 'label' => 'preset.observe', 'desc' => 'preset.observe.desc', 'min' => 2, 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5, 'interface_down' => 3, 'perf' => 2] ], 'night' => [ 'label' => 'preset.night', 'desc' => 'preset.night.desc', 'min' => 5, 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5, 'interface_down' => 2, 'perf' => 1] ], 'balanced' => [ 'label' => 'preset.balanced', 'desc' => 'preset.balanced.desc', 'min' => 3, 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5, 'interface_down' => 3, 'perf' => 2] ] ]; /** Classify one trigger description into a class key. */ public static function classifyRaw(string $desc): string { $d = strtolower($desc); foreach (self::CLASSES as $key => $def) { if (empty($def['kw'])) { continue; } $hit = false; foreach ($def['kw'] as $k) { if (strpos($d, $k) !== false) { $hit = true; break; } } if (!$hit) { continue; } if ($key === 'core_link') { if (self::hasCoreToken($d)) { return 'core_link'; } continue; // non-core link-down -> interface_down } return $key; } return 'other'; } private static function hasCoreToken(string $d): bool { foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) { if (strpos($d, $t) !== false) { return true; } } return false; } public static function templateHostid(): ?string { try { $res = API::Template()->get([ 'output' => ['templateid', 'host'], 'filter' => ['host' => self::TEMPLATE_NAME], 'limit' => 1 ]); if ($res) { return (string) $res[0]['templateid']; } $res = API::Host()->get([ 'output' => ['hostid', 'host'], 'filter' => ['host' => self::TEMPLATE_NAME], 'limit' => 1 ]); return $res ? (string) $res[0]['hostid'] : null; } catch (\Throwable $e) { return null; } } public static function severityMaskFromMin(int $min): int { $mask = 0; for ($i = max(1, $min); $i <= 6; $i++) { $mask |= (1 << ($i - 1)); } return $mask; } public static function currentStrategy(): string { $v = self::getGlobalMacro(self::STRATEGY_MACRO); return $v === null ? '' : (string) $v; } public static function saveStrategyKey(string $key): void { self::setGlobalMacro(self::STRATEGY_MACRO, $key); } private static function sqlEsc(string $s): string { return str_replace(["\\", "'"], ["\\\\", "''"], $s); } private static function classCondSql(string $key): string { $def = self::CLASSES[$key] ?? null; if (!$def || empty($def['kw'])) { return '1=0'; } $parts = []; foreach ($def['kw'] as $k) { $parts[] = "LOCATE('".self::sqlEsc($k)."', LOWER(t.description)) > 0"; } $cond = '('.implode(' OR ', $parts).')'; if ($key === 'core_link') { $cp = []; foreach (($def['core'] ?? []) as $t) { $cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0"; } $cond .= ' AND ('.implode(' OR ', $cp).')'; } elseif ($key === 'interface_down') { $cp = []; foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) { $cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0"; } $cond .= ' AND NOT ('.implode(' OR ', $cp).')'; } return $cond; } private static function scopeSql(string $tid): string { return 'EXISTS (SELECT 1 FROM functions f JOIN items i ON i.itemid=f.itemid ' .'JOIN hosts_templates ht ON ht.hostid=i.hostid ' .'WHERE f.triggerid=t.triggerid AND ht.templateid='.(int) $tid.')'; } /** Dry-run: per-class {count, changes, target} over hosted (discovered) triggers. */ public static function previewStrategy(string $tid, array $targets): array { $out = ['total' => 0, 'changes' => 0, 'classes' => []]; $scope = self::scopeSql($tid); foreach ($targets as $key => $target) { if ($key === 'other') { continue; } $cond = self::classCondSql($key); $row = DBfetch(DBselect( 'SELECT COUNT(*) AS c, SUM(CASE WHEN t.priority<>'.(int) $target.' THEN 1 ELSE 0 END) AS ch ' .'FROM triggers t WHERE t.flags=4 AND '.$scope.' AND '.$cond )); $c = $row ? (int) $row['c'] : 0; $ch = $row ? (int) $row['ch'] : 0; $out['classes'][$key] = ['count' => $c, 'changes' => $ch, 'target' => (int) $target]; $out['total'] += $c; $out['changes'] += $ch; } return $out; } /** * Apply a strategy: * - template triggers -> API trigger.update (instant propagation) * - trigger prototypes -> API triggerprototype.update (future re-discovery) * - discovered triggers -> SQL priority update (takes effect immediately) */ public static function applyStrategy(string $tid, array $targets): array { $res = ['trigger' => 0, 'prototype' => 0, 'discovered' => 0]; $trs = API::Trigger()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]); foreach ($trs as $t) { $c = self::classifyRaw((string) $t['description']); if (!isset($targets[$c]) || $c === 'other') { continue; } $tg = (int) $targets[$c]; if ((int) $t['priority'] !== $tg) { API::Trigger()->update(['triggerid' => $t['triggerid'], 'priority' => $tg]); $res['trigger']++; } } $prs = API::TriggerPrototype()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]); foreach ($prs as $p) { $c = self::classifyRaw((string) $p['description']); if (!isset($targets[$c]) || $c === 'other') { continue; } $tg = (int) $targets[$c]; if ((int) $p['priority'] !== $tg) { API::TriggerPrototype()->update(['triggerid' => $p['triggerid'], 'priority' => $tg]); $res['prototype']++; } } // Discovered triggers: low priority classes first so higher priority wins on overlap. $order = ['perf', 'interface_down', 'core_link', 'restarted', 'hw_fault', 'device_down']; $scope = self::scopeSql($tid); foreach ($order as $key) { if (!isset($targets[$key])) { continue; } $tg = (int) $targets[$key]; $cond = self::classCondSql($key); $cnt = DBfetch(DBselect( 'SELECT COUNT(*) AS c FROM triggers t WHERE t.flags=4 AND '.$scope .' AND '.$cond.' AND t.priority<>'.$tg )); $n = $cnt ? (int) $cnt['c'] : 0; if ($n > 0) { $ok = DBexecute('UPDATE triggers AS t SET t.priority='.$tg .' WHERE t.flags=4 AND '.$scope.' AND '.$cond.' AND t.priority<>'.$tg); if ($ok === false) { throw new Exception('DB update failed for class '.$key); } $res['discovered'] += $n; } } return $res; } /* ================================================================== */ /* SERVER TUNING GENERATOR (Zabbix server process / cache settings) */ /* Produces a copy-paste env block + restart command. Nothing is */ /* written here - the operator applies it on the host (zero risk). */ /* ================================================================== */ public const TUNING_PROFILES = [ 'small' => [ 'StartSNMPPollers' => 10, 'StartPollers' => 10, 'StartLLDWorkers' => 5, 'StartPreprocessors' => 3, 'CacheSize' => '256M', 'ValueCacheSize' => '64M', 'HistoryCacheSize' => '32M', 'HistoryIndexCacheSize' => '8M', 'TrendCacheSize' => '32M' ], 'medium' => [ 'StartSNMPPollers' => 30, 'StartPollers' => 20, 'StartLLDWorkers' => 10, 'StartPreprocessors' => 5, 'CacheSize' => '512M', 'ValueCacheSize' => '128M', 'HistoryCacheSize' => '64M', 'HistoryIndexCacheSize' => '16M', 'TrendCacheSize' => '64M' ], 'large' => [ 'StartSNMPPollers' => 60, 'StartPollers' => 40, 'StartLLDWorkers' => 20, 'StartPreprocessors' => 10, 'CacheSize' => '1G', 'ValueCacheSize' => '256M', 'HistoryCacheSize' => '128M', 'HistoryIndexCacheSize' => '32M', 'TrendCacheSize' => '128M' ] ]; /** * Build a compose .env / environment snippet for the chosen profile, plus * the command to recreate the server container. * @return array{env:string, cmd:string} */ public static function tuningSnippet(string $profile): array { $vals = self::TUNING_PROFILES[$profile] ?? self::TUNING_PROFILES['medium']; $lines = []; foreach ($vals as $k => $v) { $lines[] = 'ZBX_'.$k.'='.$v; } $env = implode("\n", $lines); $cmd = "# paste the ZBX_* lines into /opt/zabbix/.env (or docker-compose environment)\n" ."cd /opt/zabbix && docker compose up -d zabbix-server"; return ['env' => $env, 'cmd' => $cmd]; } }