Helper.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  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. }