Helper.php 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066
  1. <?php declare(strict_types = 0);
  2. /*
  3. ** WeCom Center - business logic helpers.
  4. ** Works through the official Zabbix API so every setting is stored in the
  5. ** Zabbix database itself -> survives image upgrades and is portable.
  6. */
  7. namespace Modules\WecomCenter\Lib;
  8. use API;
  9. use Exception;
  10. class Helper {
  11. // Zabbix trigger severity index => media severity bit (1 << index).
  12. public const SEVERITY_BITS = [1, 2, 4, 8, 16, 32];
  13. // Built-in alert schemes: name => [severity_mask, active(0=enabled,1=disabled)]
  14. public const SCHEMES = [
  15. 'standard' => [48, 0], // High + Disaster (recommended)
  16. 'warnplus' => [56, 0], // Average + High + Disaster
  17. 'all' => [63, 0], // everything
  18. 'quiet' => [32, 0], // Disaster only
  19. 'off' => [63, 1] // alerting disabled
  20. ];
  21. /* ------------------------------------------------------------------ */
  22. /* Media types / robot */
  23. /* ------------------------------------------------------------------ */
  24. /**
  25. * All webhook media types (type == 4).
  26. */
  27. public static function webhookMediatypes(): array {
  28. try {
  29. $res = API::Mediatype()->get([
  30. 'output' => ['mediatypeid', 'name', 'type', 'status'],
  31. 'selectParameters' => 'extend',
  32. 'filter' => ['type' => 4]
  33. ]);
  34. return is_array($res) ? $res : [];
  35. }
  36. catch (\Throwable $e) {
  37. return [];
  38. }
  39. }
  40. public static function getMediatype(string $mediatypeid): ?array {
  41. try {
  42. $res = API::Mediatype()->get([
  43. 'output' => ['mediatypeid', 'name', 'type', 'status'],
  44. 'selectParameters' => 'extend',
  45. 'mediatypeids' => [$mediatypeid]
  46. ]);
  47. return (is_array($res) && $res) ? $res[0] : null;
  48. }
  49. catch (\Throwable $e) {
  50. return null;
  51. }
  52. }
  53. // Fixed WeCom group-robot endpoint; the per-bot secret lives in {$WECOM_KEY}.
  54. public const WECOM_BASE = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=';
  55. public const KEY_MACRO = '{$WECOM_KEY}';
  56. /** Read a global macro value; null when missing. */
  57. public static function getGlobalMacro(string $name): ?string {
  58. try {
  59. $res = API::UserMacro()->get([
  60. 'globalmacro' => true,
  61. 'output' => ['globalmacroid', 'macro', 'value'],
  62. 'filter' => ['macro' => $name]
  63. ]);
  64. return ($res && isset($res[0])) ? (string) $res[0]['value'] : null;
  65. }
  66. catch (\Throwable $e) {
  67. return null;
  68. }
  69. }
  70. /** Create or update a global macro. */
  71. public static function setGlobalMacro(string $name, string $value): void {
  72. $res = API::UserMacro()->get([
  73. 'globalmacro' => true,
  74. 'output' => ['globalmacroid', 'macro'],
  75. 'filter' => ['macro' => $name]
  76. ]);
  77. if ($res && isset($res[0])) {
  78. API::UserMacro()->updateGlobal([
  79. ['globalmacroid' => $res[0]['globalmacroid'], 'value' => $value]
  80. ]);
  81. }
  82. else {
  83. API::UserMacro()->createGlobal([
  84. ['macro' => $name, 'value' => $value]
  85. ]);
  86. }
  87. }
  88. /**
  89. * Current robot key: {$WECOM_KEY} global macro, falling back to any legacy
  90. * value still stored in the media type parameters.
  91. */
  92. public static function webhookKey(?array $mediatype = null): string {
  93. $v = self::getGlobalMacro(self::KEY_MACRO);
  94. if ($v !== null && $v !== '') {
  95. return $v;
  96. }
  97. foreach (($mediatype['parameters'] ?? []) as $p) {
  98. $n = strtolower($p['name']);
  99. $val = (string) $p['value'];
  100. if ($n === 'key' && strpos($val, '{') === false) {
  101. return $val;
  102. }
  103. if (in_array($n, ['webhook_url', 'url', 'webhook'], true)) {
  104. if (strpos($val, 'key=') !== false) {
  105. return substr($val, strpos($val, 'key=') + 4);
  106. }
  107. if (strpos($val, 'http') !== 0) {
  108. return $val;
  109. }
  110. }
  111. }
  112. return '';
  113. }
  114. /** Persist the robot key into the {$WECOM_KEY} global macro (managed here, not in the media type). */
  115. public static function setWebhookKey(string $key): void {
  116. self::setGlobalMacro(self::KEY_MACRO, $key);
  117. }
  118. /** Full webhook URL for this bot = fixed base + {$WECOM_KEY}. */
  119. public static function webhookUrl(?array $mediatype = null): string {
  120. $key = self::webhookKey($mediatype);
  121. return $key === '' ? '' : self::WECOM_BASE.$key;
  122. }
  123. /* ------------------------------------------------------------------ */
  124. /* Robot user + media (severity / active) */
  125. /* ------------------------------------------------------------------ */
  126. /**
  127. * Find the dedicated robot user with its media.
  128. */
  129. public static function robotUser(string $username = 'wecomrobot'): ?array {
  130. $res = API::User()->get([
  131. 'output' => ['userid', 'username'],
  132. 'selectMedias' => 'extend',
  133. 'filter' => ['username' => $username]
  134. ]);
  135. return $res ? $res[0] : null;
  136. }
  137. /**
  138. * Current severity mask + active flag of a user's media for a media type.
  139. * Returns ['active'=>int, 'severity'=>int] or null when media absent.
  140. */
  141. public static function currentMedia(?array $user, string $mediatypeid): ?array {
  142. if (!$user || empty($user['medias'])) {
  143. return null;
  144. }
  145. foreach ($user['medias'] as $m) {
  146. if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
  147. return [
  148. 'active' => (int) $m['active'],
  149. 'severity' => (int) $m['severity'],
  150. 'period' => $m['period'] ?? '1-7,00:00-24:00',
  151. 'sendto' => $m['sendto'] ?? ''
  152. ];
  153. }
  154. }
  155. return null;
  156. }
  157. /**
  158. * Ensure a robot user exists; create one (no frontend access) if missing.
  159. */
  160. public static function ensureRobotUser(string $username): array {
  161. $user = self::robotUser($username);
  162. if ($user) {
  163. return $user;
  164. }
  165. // best-effort group / role for a non-login notification account
  166. $grp = API::UserGroup()->get([
  167. 'output' => ['usrgrpid', 'name'],
  168. 'filter' => ['name' => 'No access to the frontend'],
  169. 'limit' => 1
  170. ]);
  171. $usrgrpid = $grp ? $grp[0]['usrgrpid'] : null;
  172. if ($usrgrpid === null) {
  173. $all = API::UserGroup()->get(['output' => ['usrgrpid'], 'limit' => 1]);
  174. $usrgrpid = $all ? $all[0]['usrgrpid'] : null;
  175. }
  176. $role = API::Role()->get([
  177. 'output' => ['roleid', 'name'],
  178. 'filter' => ['name' => 'User role'],
  179. 'limit' => 1
  180. ]);
  181. $roleid = $role ? $role[0]['roleid'] : null;
  182. $create = [
  183. 'username' => $username,
  184. 'passwd' => bin2hex(random_bytes(16)).'Aa1!',
  185. 'usrgrps' => [['usrgrpid' => $usrgrpid]]
  186. ];
  187. if ($roleid !== null) {
  188. $create['roleid'] = $roleid;
  189. }
  190. $res = API::User()->create($create);
  191. return self::robotUser($username);
  192. }
  193. /**
  194. * Apply severity mask + active flag to a user's media for a media type.
  195. * Rebuilds the whole media list (API user.update replaces medias).
  196. */
  197. public static function applyMedia(string $userid, string $mediatypeid, int $severity, int $active,
  198. string $sendto = 'WeCom') {
  199. $res = API::User()->get([
  200. 'output' => ['userid'],
  201. 'selectMedias' => 'extend',
  202. 'userids' => [$userid]
  203. ]);
  204. $medias = [];
  205. if ($res) {
  206. foreach (($res[0]['medias'] ?? []) as $m) {
  207. $medias[] = [
  208. 'mediatypeid' => $m['mediatypeid'],
  209. 'sendto' => $m['sendto'],
  210. 'severity' => (string) $m['severity'],
  211. 'active' => (string) $m['active'],
  212. 'period' => $m['period'] ?: '1-7,00:00-24:00'
  213. ];
  214. }
  215. }
  216. $hit = false;
  217. foreach ($medias as &$m) {
  218. if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
  219. $m['severity'] = (string) $severity;
  220. $m['active'] = (string) $active;
  221. if ($sendto !== '') {
  222. $m['sendto'] = $sendto;
  223. }
  224. $hit = true;
  225. }
  226. }
  227. unset($m);
  228. if (!$hit) {
  229. $medias[] = [
  230. 'mediatypeid' => $mediatypeid,
  231. 'sendto' => $sendto !== '' ? $sendto : 'WeCom',
  232. 'severity' => (string) $severity,
  233. 'active' => (string) $active,
  234. 'period' => '1-7,00:00-24:00'
  235. ];
  236. }
  237. API::User()->update(['userid' => $userid, 'medias' => $medias]);
  238. }
  239. /* ------------------------------------------------------------------ */
  240. /* Send test message */
  241. /* ------------------------------------------------------------------ */
  242. public static function testWebhook(string $url, string $text): array {
  243. $payload = json_encode(['msgtype' => 'text', 'text' => ['content' => $text]], JSON_UNESCAPED_UNICODE);
  244. $ch = curl_init($url);
  245. curl_setopt_array($ch, [
  246. CURLOPT_RETURNTRANSFER => true,
  247. CURLOPT_POST => true,
  248. CURLOPT_POSTFIELDS => $payload,
  249. CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
  250. CURLOPT_TIMEOUT => 15,
  251. CURLOPT_SSL_VERIFYPEER => false,
  252. CURLOPT_SSL_VERIFYHOST => 0
  253. ]);
  254. $body = curl_exec($ch);
  255. $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  256. $err = curl_error($ch);
  257. curl_close($ch);
  258. return ['status' => $status, 'body' => $body !== false ? $body : $err];
  259. }
  260. /* ------------------------------------------------------------------ */
  261. /* Templates */
  262. /* ------------------------------------------------------------------ */
  263. /**
  264. * Bundled templates shipped inside modules/WecomCenter/templates/.
  265. */
  266. public static function bundledTemplates(string $module_dir): array {
  267. $dir = rtrim($module_dir, '/').'/templates';
  268. $out = [];
  269. if (!is_dir($dir)) {
  270. return $out;
  271. }
  272. foreach (scandir($dir) as $f) {
  273. if ($f === '.' || $f === '..') {
  274. continue;
  275. }
  276. $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
  277. if (in_array($ext, ['xml', 'yaml', 'yml', 'json'], true)) {
  278. $out[] = ['file' => $f, 'name' => pathinfo($f, PATHINFO_FILENAME)];
  279. }
  280. }
  281. return $out;
  282. }
  283. public static function importTemplate(string $module_dir, string $file): array {
  284. $dir = rtrim($module_dir, '/').'/templates';
  285. $file = basename($file);
  286. $path = $dir.'/'.$file;
  287. if (!is_file($path)) {
  288. throw new Exception('Template file not found: '.$file);
  289. }
  290. $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
  291. $format = ['xml' => 'xml', 'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml'][$ext] ?? 'yaml';
  292. $source = file_get_contents($path);
  293. $result = API::Configuration()->import([
  294. 'format' => $format,
  295. 'source' => $source,
  296. 'rules' => [
  297. 'template_groups' => ['createMissing' => true, 'updateExisting' => true],
  298. 'templates' => ['createMissing' => true, 'updateExisting' => true],
  299. 'items' => ['createMissing' => true, 'updateExisting' => true],
  300. 'triggers' => ['createMissing' => true, 'updateExisting' => true]
  301. ]
  302. ]);
  303. return is_array($result) ? $result : [];
  304. }
  305. /* ------------------------------------------------------------------ */
  306. /* Map coordinates */
  307. /* ------------------------------------------------------------------ */
  308. public static function hosts(): array {
  309. return API::Host()->get([
  310. 'output' => ['hostid', 'name'],
  311. 'sortfield' => 'name',
  312. 'limit' => 1000
  313. ]);
  314. }
  315. public static function setHostLocation(string $hostid, string $lat, string $lon): void {
  316. API::Host()->update([
  317. 'hostid' => $hostid,
  318. 'inventory_mode' => 0, // manual
  319. 'inventory' => [
  320. 'location_lat' => $lat,
  321. 'location_lon' => $lon
  322. ]
  323. ]);
  324. }
  325. public static function hostLocation(string $hostid): array {
  326. $res = API::Host()->get([
  327. 'output' => ['hostid', 'name'],
  328. 'selectInventory' => ['location_lat', 'location_lon'],
  329. 'hostids' => [$hostid]
  330. ]);
  331. return $res ? $res[0] : [];
  332. }
  333. /* ------------------------------------------------------------------ */
  334. /* Helpers */
  335. /* ------------------------------------------------------------------ */
  336. public static function severityMask(array $indices): int {
  337. $mask = 0;
  338. foreach ($indices as $i) {
  339. $i = (int) $i;
  340. if ($i >= 1 && $i <= 6) {
  341. $mask |= self::SEVERITY_BITS[$i - 1];
  342. }
  343. }
  344. return $mask;
  345. }
  346. /* ------------------------------------------------------------------ */
  347. /* Triggers (per-trigger alerting switch) */
  348. /* ------------------------------------------------------------------ */
  349. /**
  350. * Search triggers by description. An empty search returns nothing on
  351. * purpose - the instance has tens of thousands of triggers, listing all
  352. * is pointless. Results are capped.
  353. */
  354. public static function triggers(string $search, int $limit = 200): array {
  355. $search = trim($search);
  356. if ($search === '') {
  357. return [];
  358. }
  359. try {
  360. $res = API::Trigger()->get([
  361. 'output' => ['triggerid', 'description', 'status', 'priority'],
  362. 'selectHosts' => ['host'],
  363. 'search' => ['description' => $search],
  364. 'limit' => $limit
  365. ]);
  366. return is_array($res) ? $res : [];
  367. }
  368. catch (\Throwable $e) {
  369. return [];
  370. }
  371. }
  372. public static function triggerCount(): int {
  373. try {
  374. $n = API::Trigger()->get(['countOutput' => true]);
  375. return (int) $n;
  376. }
  377. catch (\Throwable $e) {
  378. return 0;
  379. }
  380. }
  381. /**
  382. * Set trigger status. $map = [triggerid => status(0 enabled,1 disabled)].
  383. * NB: this Zabbix version's trigger.update does NOT accept a "triggers"
  384. * array, so we update one trigger at a time.
  385. */
  386. public static function setTriggersStatus(array $map): int {
  387. $changed = 0;
  388. foreach ($map as $tid => $status) {
  389. $res = API::Trigger()->update([
  390. 'triggerid' => (string) $tid,
  391. 'status' => (string) ((int) $status)
  392. ]);
  393. if ($res === false) {
  394. throw new Exception('trigger.update failed for '.$tid);
  395. }
  396. $changed++;
  397. }
  398. return $changed;
  399. }
  400. /**
  401. * Set severity (priority 0-5) and/or enabled-status (0 enabled / 1 disabled)
  402. * for a single trigger. Handles discovered (LLD) triggers, which cannot be
  403. * updated through the API, by writing directly to the triggers table.
  404. * Pass null to leave a field untouched.
  405. */
  406. public static function setTriggerState(string $triggerid, ?int $priority, ?int $status): bool {
  407. $tid = (string) (int) $triggerid;
  408. if ($tid === '' || $tid === '0') {
  409. return false;
  410. }
  411. $cur = API::Trigger()->get([
  412. 'triggerids' => [$tid],
  413. 'output' => ['triggerid', 'priority', 'status', 'flags']
  414. ]);
  415. if (!$cur) {
  416. return false;
  417. }
  418. $flags = (int) $cur[0]['flags'];
  419. if ($priority !== null) {
  420. $priority = max(0, min(5, $priority));
  421. }
  422. if ($flags === 4) { // discovered trigger -> SQL only
  423. $sets = [];
  424. if ($priority !== null) {
  425. $sets[] = 'priority='.$priority;
  426. }
  427. if ($status !== null) {
  428. $sets[] = 'status='.((int) $status);
  429. }
  430. if ($sets) {
  431. DBexecute('UPDATE triggers SET '.implode(',', $sets).' WHERE triggerid='.$tid);
  432. }
  433. return true;
  434. }
  435. $upd = ['triggerid' => $tid];
  436. if ($priority !== null && (int) $cur[0]['priority'] !== $priority) {
  437. $upd['priority'] = $priority;
  438. }
  439. if ($status !== null && (int) $cur[0]['status'] !== (int) $status) {
  440. $upd['status'] = (string) ((int) $status);
  441. }
  442. if (count($upd) > 1) {
  443. API::Trigger()->update($upd);
  444. }
  445. return true;
  446. }
  447. /**
  448. * Set one whole class of triggers to a severity, across:
  449. * - template triggers (API) - prototypes (API) - discovered triggers (SQL)
  450. * Reuses the strategy engine so behaviour is identical to presets.
  451. * @return array{trigger:int,prototype:int,discovered:int}
  452. */
  453. public static function setClassSeverity(string $classKey, int $priority): array {
  454. if ($classKey === 'other' || !isset(self::CLASSES[$classKey])) {
  455. throw new Exception('Unknown or unsupported class: '.$classKey);
  456. }
  457. $tid = self::templateHostid();
  458. if ($tid === null) {
  459. throw new Exception('Template not found: '.self::TEMPLATE_NAME);
  460. }
  461. return self::applyStrategy($tid, [$classKey => max(0, min(5, $priority))]);
  462. }
  463. /* ================================================================== */
  464. /* ALERT STRATEGY ENGINE */
  465. /* Classify triggers by name keyword, map to severity, apply to the */
  466. /* template triggers + prototypes (API) and discovered triggers (SQL)*/
  467. /* ================================================================== */
  468. public const TEMPLATE_NAME = 'Huawei VRP by SNMP walk';
  469. public const STRATEGY_MACRO = '{$WECOM_STRATEGY}';
  470. // Ordered classes - first match wins (highest priority first).
  471. public const CLASSES = [
  472. 'device_down' => [
  473. 'label' => 'cls.device_down',
  474. 'kw' => ['unavailable by icmp', 'icmp ping loss', 'no snmp data', 'no data', 'unreachable']
  475. ],
  476. 'hw_fault' => [
  477. 'label' => 'cls.hw_fault',
  478. 'kw' => ['fan is in critical', 'power supply', 'temperature is above critical',
  479. 'temperature is too low', 'is in critical state', 'psu']
  480. ],
  481. 'restarted' => [
  482. 'label' => 'cls.restarted',
  483. 'kw' => ['has been restarted', 'has been rebooted']
  484. ],
  485. 'core_link' => [
  486. 'label' => 'cls.core_link',
  487. 'kw' => ['link down'],
  488. 'core' => ['to:', 'dist', 'core', 'csw', 'uplink', 'aggregat', '汇聚', 'trunk']
  489. ],
  490. 'interface_down' => [
  491. 'label' => 'cls.interface_down',
  492. 'kw' => ['link down']
  493. ],
  494. 'perf' => [
  495. 'label' => 'cls.perf',
  496. 'kw' => ['high bandwidth usage', 'high error rate', 'high cpu', 'high memory', 'utilization',
  497. 'high icmp ping response time', 'changed to lower speed', 'temperature is above warning']
  498. ],
  499. 'other' => ['label' => 'cls.other', 'kw' => []]
  500. ];
  501. // Selectable alert strategies. min = lowest severity index that still notifies.
  502. public const STRATEGY_PRESETS = [
  503. 'steady' => [
  504. 'label' => 'preset.steady', 'desc' => 'preset.steady.desc', 'min' => 4,
  505. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  506. 'interface_down' => 3, 'perf' => 2]
  507. ],
  508. 'core' => [
  509. 'label' => 'preset.core', 'desc' => 'preset.core.desc', 'min' => 5,
  510. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  511. 'interface_down' => 2, 'perf' => 1]
  512. ],
  513. 'observe' => [
  514. 'label' => 'preset.observe', 'desc' => 'preset.observe.desc', 'min' => 2,
  515. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  516. 'interface_down' => 3, 'perf' => 2]
  517. ],
  518. 'night' => [
  519. 'label' => 'preset.night', 'desc' => 'preset.night.desc', 'min' => 5,
  520. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  521. 'interface_down' => 2, 'perf' => 1]
  522. ],
  523. 'balanced' => [
  524. 'label' => 'preset.balanced', 'desc' => 'preset.balanced.desc', 'min' => 3,
  525. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  526. 'interface_down' => 3, 'perf' => 2]
  527. ]
  528. ];
  529. /** Classify one trigger description into a class key. */
  530. public static function classifyRaw(string $desc): string {
  531. $d = strtolower($desc);
  532. foreach (self::CLASSES as $key => $def) {
  533. if (empty($def['kw'])) {
  534. continue;
  535. }
  536. $hit = false;
  537. foreach ($def['kw'] as $k) {
  538. if (strpos($d, $k) !== false) {
  539. $hit = true;
  540. break;
  541. }
  542. }
  543. if (!$hit) {
  544. continue;
  545. }
  546. if ($key === 'core_link') {
  547. if (self::hasCoreToken($d)) {
  548. return 'core_link';
  549. }
  550. continue; // non-core link-down -> interface_down
  551. }
  552. return $key;
  553. }
  554. return 'other';
  555. }
  556. private static function hasCoreToken(string $d): bool {
  557. foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) {
  558. if (strpos($d, $t) !== false) {
  559. return true;
  560. }
  561. }
  562. return false;
  563. }
  564. public static function templateHostid(): ?string {
  565. try {
  566. $res = API::Template()->get([
  567. 'output' => ['templateid', 'host'],
  568. 'filter' => ['host' => self::TEMPLATE_NAME],
  569. 'limit' => 1
  570. ]);
  571. if ($res) {
  572. return (string) $res[0]['templateid'];
  573. }
  574. $res = API::Host()->get([
  575. 'output' => ['hostid', 'host'],
  576. 'filter' => ['host' => self::TEMPLATE_NAME],
  577. 'limit' => 1
  578. ]);
  579. return $res ? (string) $res[0]['hostid'] : null;
  580. }
  581. catch (\Throwable $e) {
  582. return null;
  583. }
  584. }
  585. public static function severityMaskFromMin(int $min): int {
  586. $mask = 0;
  587. for ($i = max(1, $min); $i <= 6; $i++) {
  588. $mask |= (1 << ($i - 1));
  589. }
  590. return $mask;
  591. }
  592. public static function currentStrategy(): string {
  593. $v = self::getGlobalMacro(self::STRATEGY_MACRO);
  594. return $v === null ? '' : (string) $v;
  595. }
  596. public static function saveStrategyKey(string $key): void {
  597. self::setGlobalMacro(self::STRATEGY_MACRO, $key);
  598. }
  599. private static function sqlEsc(string $s): string {
  600. return str_replace(["\\", "'"], ["\\\\", "''"], $s);
  601. }
  602. private static function classCondSql(string $key): string {
  603. $def = self::CLASSES[$key] ?? null;
  604. if (!$def || empty($def['kw'])) {
  605. return '1=0';
  606. }
  607. $parts = [];
  608. foreach ($def['kw'] as $k) {
  609. $parts[] = "LOCATE('".self::sqlEsc($k)."', LOWER(t.description)) > 0";
  610. }
  611. $cond = '('.implode(' OR ', $parts).')';
  612. if ($key === 'core_link') {
  613. $cp = [];
  614. foreach (($def['core'] ?? []) as $t) {
  615. $cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0";
  616. }
  617. $cond .= ' AND ('.implode(' OR ', $cp).')';
  618. }
  619. elseif ($key === 'interface_down') {
  620. $cp = [];
  621. foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) {
  622. $cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0";
  623. }
  624. $cond .= ' AND NOT ('.implode(' OR ', $cp).')';
  625. }
  626. return $cond;
  627. }
  628. private static function scopeSql(string $tid): string {
  629. return 'EXISTS (SELECT 1 FROM functions f JOIN items i ON i.itemid=f.itemid '
  630. .'JOIN hosts_templates ht ON ht.hostid=i.hostid '
  631. .'WHERE f.triggerid=t.triggerid AND ht.templateid='.(int) $tid.')';
  632. }
  633. /** Dry-run: per-class {count, changes, target} over hosted (discovered) triggers. */
  634. public static function previewStrategy(string $tid, array $targets): array {
  635. $out = ['total' => 0, 'changes' => 0, 'classes' => []];
  636. $scope = self::scopeSql($tid);
  637. foreach ($targets as $key => $target) {
  638. if ($key === 'other') {
  639. continue;
  640. }
  641. $cond = self::classCondSql($key);
  642. $row = DBfetch(DBselect(
  643. 'SELECT COUNT(*) AS c, SUM(CASE WHEN t.priority<>'.(int) $target.' THEN 1 ELSE 0 END) AS ch '
  644. .'FROM triggers t WHERE t.flags=4 AND '.$scope.' AND '.$cond
  645. ));
  646. $c = $row ? (int) $row['c'] : 0;
  647. $ch = $row ? (int) $row['ch'] : 0;
  648. $out['classes'][$key] = ['count' => $c, 'changes' => $ch, 'target' => (int) $target];
  649. $out['total'] += $c;
  650. $out['changes'] += $ch;
  651. }
  652. return $out;
  653. }
  654. /**
  655. * Apply a strategy:
  656. * - template triggers -> API trigger.update (instant propagation)
  657. * - trigger prototypes -> API triggerprototype.update (future re-discovery)
  658. * - discovered triggers -> SQL priority update (takes effect immediately)
  659. */
  660. public static function applyStrategy(string $tid, array $targets): array {
  661. $res = ['trigger' => 0, 'prototype' => 0, 'discovered' => 0];
  662. $trs = API::Trigger()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]);
  663. foreach ($trs as $t) {
  664. $c = self::classifyRaw((string) $t['description']);
  665. if (!isset($targets[$c]) || $c === 'other') {
  666. continue;
  667. }
  668. $tg = (int) $targets[$c];
  669. if ((int) $t['priority'] !== $tg) {
  670. API::Trigger()->update(['triggerid' => $t['triggerid'], 'priority' => $tg]);
  671. $res['trigger']++;
  672. }
  673. }
  674. $prs = API::TriggerPrototype()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]);
  675. foreach ($prs as $p) {
  676. $c = self::classifyRaw((string) $p['description']);
  677. if (!isset($targets[$c]) || $c === 'other') {
  678. continue;
  679. }
  680. $tg = (int) $targets[$c];
  681. if ((int) $p['priority'] !== $tg) {
  682. API::TriggerPrototype()->update(['triggerid' => $p['triggerid'], 'priority' => $tg]);
  683. $res['prototype']++;
  684. }
  685. }
  686. // Discovered triggers: low priority classes first so higher priority wins on overlap.
  687. $order = ['perf', 'interface_down', 'core_link', 'restarted', 'hw_fault', 'device_down'];
  688. $scope = self::scopeSql($tid);
  689. foreach ($order as $key) {
  690. if (!isset($targets[$key])) {
  691. continue;
  692. }
  693. $tg = (int) $targets[$key];
  694. $cond = self::classCondSql($key);
  695. $cnt = DBfetch(DBselect(
  696. 'SELECT COUNT(*) AS c FROM triggers t WHERE t.flags=4 AND '.$scope
  697. .' AND '.$cond.' AND t.priority<>'.$tg
  698. ));
  699. $n = $cnt ? (int) $cnt['c'] : 0;
  700. if ($n > 0) {
  701. $ok = DBexecute('UPDATE triggers AS t SET t.priority='.$tg
  702. .' WHERE t.flags=4 AND '.$scope.' AND '.$cond.' AND t.priority<>'.$tg);
  703. if ($ok === false) {
  704. throw new Exception('DB update failed for class '.$key);
  705. }
  706. $res['discovered'] += $n;
  707. }
  708. }
  709. return $res;
  710. }
  711. /* ================================================================== */
  712. /* SERVER TUNING GENERATOR (Zabbix server process / cache settings) */
  713. /* Produces a copy-paste env block + restart command. Nothing is */
  714. /* written here - the operator applies it on the host (zero risk). */
  715. /* ================================================================== */
  716. public const TUNING_PROFILES = [
  717. 'small' => [
  718. 'StartSNMPPollers' => 10, 'StartPollers' => 10, 'StartLLDWorkers' => 5,
  719. 'StartPreprocessors' => 3, 'CacheSize' => '256M', 'ValueCacheSize' => '64M',
  720. 'HistoryCacheSize' => '32M', 'HistoryIndexCacheSize' => '8M', 'TrendCacheSize' => '32M'
  721. ],
  722. 'medium' => [
  723. 'StartSNMPPollers' => 30, 'StartPollers' => 20, 'StartLLDWorkers' => 10,
  724. 'StartPreprocessors' => 5, 'CacheSize' => '512M', 'ValueCacheSize' => '128M',
  725. 'HistoryCacheSize' => '64M', 'HistoryIndexCacheSize' => '16M', 'TrendCacheSize' => '64M'
  726. ],
  727. 'large' => [
  728. 'StartSNMPPollers' => 60, 'StartPollers' => 40, 'StartLLDWorkers' => 20,
  729. 'StartPreprocessors' => 10, 'CacheSize' => '1G', 'ValueCacheSize' => '256M',
  730. 'HistoryCacheSize' => '128M', 'HistoryIndexCacheSize' => '32M', 'TrendCacheSize' => '128M'
  731. ]
  732. ];
  733. /**
  734. * Build a compose .env / environment snippet for the chosen profile, plus
  735. * the command to recreate the server container.
  736. * @return array{env:string, cmd:string}
  737. */
  738. public static function tuningSnippet(string $profile): array {
  739. $vals = self::TUNING_PROFILES[$profile] ?? self::TUNING_PROFILES['medium'];
  740. $lines = [];
  741. foreach ($vals as $k => $v) {
  742. $lines[] = 'ZBX_'.$k.'='.$v;
  743. }
  744. $env = implode("\n", $lines);
  745. $cmd = "# paste the ZBX_* lines into /opt/zabbix/.env (or docker-compose environment)\n"
  746. ."cd /opt/zabbix && docker compose up -d zabbix-server";
  747. return ['env' => $env, 'cmd' => $cmd];
  748. }
  749. /* ================================================================== */
  750. /* TEMPLATE SWITCH / MIGRATION (old template -> new template) */
  751. /* ================================================================== */
  752. /** Huawei-related templates available on this server (source/target candidates). */
  753. public static function huaweiTemplates(): array {
  754. try {
  755. $res = API::Template()->get([
  756. 'output' => ['templateid', 'host', 'name'],
  757. 'search' => ['host' => 'Huawei'],
  758. 'sortfield' => 'host'
  759. ]);
  760. return $res ?: [];
  761. }
  762. catch (\Throwable $e) {
  763. return [];
  764. }
  765. }
  766. /** Hosts currently linked to the given template. */
  767. public static function hostsOnTemplate(string $tid): array {
  768. $res = API::Host()->get([
  769. 'output' => ['hostid', 'host', 'name'],
  770. 'templateids' => [$tid],
  771. 'selectParentTemplates' => ['templateid'],
  772. 'sortfield' => 'name'
  773. ]);
  774. return $res ?: [];
  775. }
  776. public static function templateNames(array $ids): array {
  777. $out = [];
  778. if (!$ids) {
  779. return $out;
  780. }
  781. $res = API::Template()->get(['output' => ['templateid', 'host'], 'templateids' => $ids]);
  782. foreach ($res as $t) {
  783. $out[$t['templateid']] = $t['host'];
  784. }
  785. return $out;
  786. }
  787. /** Dry-run: which hosts would move from $src to $dst. */
  788. public static function switchPreview(string $src, string $dst): array {
  789. $hosts = self::hostsOnTemplate($src);
  790. $names = [];
  791. foreach ($hosts as $h) {
  792. $names[] = ($h['name'] !== '') ? $h['name'] : $h['host'];
  793. }
  794. $tn = self::templateNames([$src, $dst]);
  795. return [
  796. 'src' => $src,
  797. 'dst' => $dst,
  798. 'src_name' => $tn[$src] ?? $src,
  799. 'dst_name' => $tn[$dst] ?? $dst,
  800. 'count' => count($hosts),
  801. 'hosts' => array_slice($names, 0, 300)
  802. ];
  803. }
  804. /** Replace the source template link with the target for every host on source. */
  805. public static function switchApply(string $src, string $dst): int {
  806. if ($src === $dst) {
  807. throw new Exception('Source and target template are the same.');
  808. }
  809. $hosts = self::hostsOnTemplate($src);
  810. $n = 0;
  811. foreach ($hosts as $h) {
  812. $new = [];
  813. $has_dst = false;
  814. foreach (($h['parentTemplates'] ?? []) as $pt) {
  815. if ((string) $pt['templateid'] === $src) {
  816. continue;
  817. }
  818. if ((string) $pt['templateid'] === $dst) {
  819. $has_dst = true;
  820. }
  821. $new[] = ['templateid' => $pt['templateid']];
  822. }
  823. if (!$has_dst) {
  824. $new[] = ['templateid' => $dst];
  825. }
  826. API::Host()->update(['hostid' => $h['hostid'], 'templates' => $new]);
  827. $n++;
  828. }
  829. return $n;
  830. }
  831. /* ================================================================== */
  832. /* ONE-CLICK ENVIRONMENT INITIALISATION (fresh Zabbix bootstrap) */
  833. /* Creates whatever is missing so the module works out of the box: */
  834. /* {$WECOM_KEY} macro, webhook media type, robot user + media, */
  835. /* and the trigger action. Existing objects are reused, never duped. */
  836. /* ================================================================== */
  837. public const MEDIATYPE_NAME = 'WeCom Group Robot';
  838. public const ROBOT_USERNAME = 'wecomrobot';
  839. public const ACTION_NAME = 'Send alarm by Wecom Robot';
  840. public const ZBX_URL_MACRO = '{$ZBX_URL}';
  841. public const WEBHOOK_SCRIPT = <<<'JS'
  842. var params = JSON.parse(value);
  843. var content = params.message || 'Zabbix';
  844. var url = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=' + params.key;
  845. function send(payload){
  846. var r = new HttpRequest();
  847. r.addHeader('Content-Type: application/json');
  848. var resp = r.post(url, JSON.stringify(payload));
  849. return {status: r.getStatus(), body: resp};
  850. }
  851. var res = send({msgtype: 'markdown', markdown: {content: content}});
  852. if (res.status !== 200) {
  853. var res2 = send({msgtype: 'text', text: {content: content}});
  854. if (res2.status !== 200) {
  855. Zabbix.Log(4, 'WeCom webhook error HTTP ' + res.status + ' body: ' + res.body);
  856. throw 'HTTP ' + res.status + ' => ' + res.body;
  857. }
  858. }
  859. return 'Message sent successfully';
  860. JS;
  861. /** Find an existing WeCom webhook media type (ours by name, else one bound to {$WECOM_KEY}). */
  862. public static function findWebhookMediatype(): ?array {
  863. $mts = self::webhookMediatypes();
  864. foreach ($mts as $m) {
  865. if (isset($m['name']) && $m['name'] === self::MEDIATYPE_NAME) {
  866. return $m;
  867. }
  868. }
  869. foreach ($mts as $m) {
  870. foreach (($m['parameters'] ?? []) as $p) {
  871. if (strtolower((string) $p['name']) === 'key' && strpos((string) $p['value'], 'WECOM_KEY') !== false) {
  872. return $m;
  873. }
  874. }
  875. }
  876. foreach ($mts as $m) {
  877. if (stripos((string) $m['name'], 'wecom robot') !== false) {
  878. return $m;
  879. }
  880. }
  881. return null;
  882. }
  883. /** Ensure the WeCom webhook media type exists. @return array{0:string,1:bool} [mediatypeid, created] */
  884. public static function ensureMediatype(): array {
  885. $found = self::findWebhookMediatype();
  886. if ($found) {
  887. return [(string) $found['mediatypeid'], false];
  888. }
  889. $res = API::Mediatype()->create([
  890. 'name' => self::MEDIATYPE_NAME,
  891. 'type' => 4, // webhook
  892. 'status' => 0, // enabled
  893. 'message_format' => 1, // markdown
  894. 'description' => 'Created by WeCom Center: posts alerts to a WeCom group robot.',
  895. 'timeout' => '30s',
  896. 'script' => self::WEBHOOK_SCRIPT,
  897. 'parameters' => [
  898. ['name' => 'message', 'value' => '{ALERT.MESSAGE}'],
  899. ['name' => 'key', 'value' => self::KEY_MACRO]
  900. ]
  901. ]);
  902. return [(string) $res['mediatypeids'][0], true];
  903. }
  904. /** Ensure the trigger action exists and points to the robot user + media type. */
  905. public static function ensureAction(string $mediatypeid, string $userid): bool {
  906. $ex = API::Action()->get([
  907. 'output' => ['actionid'],
  908. 'filter' => ['name' => self::ACTION_NAME]
  909. ]);
  910. if ($ex) {
  911. return false;
  912. }
  913. $problem = "<font color=\"warning\">**🔴 {TRIGGER.SEVERITY} · {HOST.NAME}**</font>\n"
  914. ."> 问题:{TRIGGER.NAME}\n"
  915. ."> 时间:{EVENT.DATE} {EVENT.TIME} | 持续:{EVENT.AGE}\n"
  916. ."> 当前值:{ITEM.LASTVALUE}\n"
  917. ."[查看详情]({$ZBX_URL}/tr_events.php?triggerid={TRIGGER.ID}&eventid={EVENT.ID})";
  918. $recovery = "<font color=\"info\">**🟢 已恢复 · {HOST.NAME}**</font>\n"
  919. ."> 问题:{TRIGGER.NAME}\n"
  920. ."> 恢复时间:{EVENT.RECOVERY.DATE} {EVENT.RECOVERY.TIME}\n"
  921. ."> 故障持续:{EVENT.DURATION}\n"
  922. ."[查看详情]({$ZBX_URL}/tr_events.php?triggerid={TRIGGER.ID}&eventid={EVENT.ID})";
  923. API::Action()->create([
  924. 'name' => self::ACTION_NAME,
  925. 'eventsource' => 0, // trigger actions
  926. 'status' => 0,
  927. 'esc_period' => '10m',
  928. 'filter' => ['evaltype' => 0, 'conditions' => []],
  929. 'operations' => [[
  930. 'operationtype' => 0,
  931. 'esc_step_from' => 1,
  932. 'esc_step_to' => 1,
  933. 'opmessage' => [
  934. 'default_msg' => 0,
  935. 'subject' => '[Zabbix Alarm] {TRIGGER.NAME}',
  936. 'message' => $problem,
  937. 'mediatypeid' => $mediatypeid
  938. ],
  939. 'opmessage_usr' => [['userid' => $userid]]
  940. ]],
  941. 'recovery_operations' => [[
  942. 'operationtype' => 0,
  943. 'opmessage' => [
  944. 'default_msg' => 0,
  945. 'subject' => '[Zabbix Recovery] {TRIGGER.NAME}',
  946. 'message' => $recovery,
  947. 'mediatypeid' => $mediatypeid
  948. ],
  949. 'opmessage_usr' => [['userid' => $userid]]
  950. ]]
  951. ]);
  952. return true;
  953. }
  954. /**
  955. * Idempotent bootstrap. Safe to run repeatedly.
  956. * @return array{key_set:bool,zbx_url:bool,mediatypeid:string,created_mediatype:bool,created_user:bool,created_action:bool}
  957. */
  958. public static function initializeEnvironment(string $key, string $zbxUrl = ''): array {
  959. $out = [
  960. 'key_set' => false, 'zbx_url' => false, 'mediatypeid' => '',
  961. 'created_mediatype' => false, 'created_user' => false, 'created_action' => false
  962. ];
  963. if ($key !== '') {
  964. self::setWebhookKey($key);
  965. $out['key_set'] = true;
  966. }
  967. if ($zbxUrl !== '' && self::getGlobalMacro(self::ZBX_URL_MACRO) === null) {
  968. self::setGlobalMacro(self::ZBX_URL_MACRO, $zbxUrl);
  969. $out['zbx_url'] = true;
  970. }
  971. [$mtid, $createdMt] = self::ensureMediatype();
  972. $out['mediatypeid'] = $mtid;
  973. $out['created_mediatype'] = $createdMt;
  974. $existed = self::robotUser(self::ROBOT_USERNAME) !== null;
  975. $user = self::ensureRobotUser(self::ROBOT_USERNAME);
  976. $out['created_user'] = !$existed;
  977. $uid = $user['userid'] ?? '';
  978. if ($uid !== '') {
  979. self::applyMedia((string) $uid, $mtid, 48, 0); // High + Disaster, enabled
  980. $out['created_action'] = self::ensureAction($mtid, (string) $uid);
  981. }
  982. return $out;
  983. }
  984. }