, , const HW_QUERIES = [ 'cpu_sockets' => [HardwareInfo::MAINBOARD, 'cpu-sockets', false], 'cpu_cores' => [HardwareInfo::MAINBOARD, 'cpu-cores', false], 'cpu_threads' => [HardwareInfo::MAINBOARD, 'cpu-threads', false], 'ram_max' => [HardwareInfo::MAINBOARD, 'Memory Maximum Capacity', true], 'ram_slots' => [HardwareInfo::MAINBOARD, 'Memory Slot Count', true], 'ram_manufacturer' => [HardwareInfo::RAM_MODULE, 'Manufacturer', true], 'ram_part_no' => [HardwareInfo::RAM_MODULE, 'Part Number', true], 'ram_speed_design' => [HardwareInfo::RAM_MODULE, 'Speed', true], 'ram_speed_current' => [HardwareInfo::RAM_MODULE, 'Configured Memory Speed', false], 'ram_size' => [HardwareInfo::RAM_MODULE, 'Size', true], 'ram_type' => [HardwareInfo::RAM_MODULE, 'Type', true], 'ram_form_factor' => [HardwareInfo::RAM_MODULE, 'Form Factor', true], 'ram_serial_no' => [HardwareInfo::RAM_MODULE, 'Serial Number', false], 'ram_voltage_min' => [HardwareInfo::RAM_MODULE, 'Minimum Voltage', true], 'ram_voltage_max' => [HardwareInfo::RAM_MODULE, 'Maximum Voltage', true], 'ram_voltage_current' => [HardwareInfo::RAM_MODULE, 'Configured Voltage', false], 'mobo_manufacturer' => [HardwareInfo::MAINBOARD, 'Manufacturer', true], 'mobo_product' => [HardwareInfo::MAINBOARD, 'Product Name', true], 'mobo_type' => [HardwareInfo::MAINBOARD, 'Type', true], 'mobo_version' => [HardwareInfo::MAINBOARD, 'Version', true], 'mobo_serial_no' => [HardwareInfo::MAINBOARD, 'Serial Number', false], 'mobo_asset_tag' => [HardwareInfo::MAINBOARD, 'Asset Tag', false], 'sys_manufacturer' => [HardwareInfo::DMI_SYSTEM, 'Manufacturer', true], 'sys_product' => [HardwareInfo::DMI_SYSTEM, 'Product Name', true], 'sys_version' => [HardwareInfo::DMI_SYSTEM, 'Version', true], 'sys_wakeup_type' => [HardwareInfo::DMI_SYSTEM, 'Wake-up Type', true], 'sys_serial_no' => [HardwareInfo::DMI_SYSTEM, 'Serial Number', false], 'sys_uuid' => [HardwareInfo::DMI_SYSTEM, 'UUID', false], 'sys_sku' => [HardwareInfo::DMI_SYSTEM, 'SKU Number', false], 'pci_class' => [HardwareInfo::PCI_DEVICE, 'class', true], 'pci_vendor' => [HardwareInfo::PCI_DEVICE, 'vendor', true], 'pci_device' => [HardwareInfo::PCI_DEVICE, 'device', true], 'hdd_ifspeed' => [HardwareInfo::HDD, 'interface_speed//max', true], 'hdd_blocksize' => [HardwareInfo::HDD, 'physical_block_size', true], 'hdd_rpm' => [HardwareInfo::HDD, 'rotation_rate', true], 'hdd_size' => [HardwareInfo::HDD, 'size', true], 'hdd_sata_version' => [HardwareInfo::HDD, 'sata_version', true], 'hdd_model' => [HardwareInfo::HDD, 'model', true], 'nic_speed' => [HardwareInfo::MAINBOARD, 'nic-speed', false], 'nic_duplex' => [HardwareInfo::MAINBOARD, 'nic-duplex', false], ]; /* * filter: * [ * [path, op, arg, result], * ... * ] * * path: slash separated path in multi-dimensional array. Supports "*" for everything on a level * op: <, >, = etc, or "regex" * arg: what to match via op * result: if not empty, a string that's added to the fired event. use %1% for the matched value (simple ops), * or %n% for capture group of regex. supports a couple suffixes like b for bytes, which will turn * a byte value into a human readable string, eg %1b% will turn 1234567 into 1.18MiB. * ts = timestamp, d = duration. */ /** * Called from anywhere within slx-admin when some form of event happens. * @param string $type the event. Will either be client state like ~poweron, ~runstate etc. or a client log type * @param array $data A structured array containing event specific data that can be matched. */ public static function applyFilterRules(string $type, array $data) { static $lastType; // Kinda hacky - if there's a "data" key in the array, and it starts with '{', // we assume it's the large machine hw info blob and discard it. if (isset($data['data']) && $data['data'][0] === '{') { unset($data['data']); } if ($lastType !== $type) { $lastType = $type; $exists = Database::queryFirst("SELECT type FROM notification_sample WHERE type = :type AND dateline > UNIX_TIMESTAMP() - 3600 LIMIT 1", ['type' => $type]); if ($exists === false) { Database::exec("INSERT INTO notification_sample (type, dateline, data) VALUES (:type, UNIX_TIMESTAMP(), :data)", [ 'type' => $type, 'data' => json_encode($data), ]); } } $types = explode('-', $type); for ($i = 1; $i < count($types); ++$i) { $types[$i] = $types[$i-1] . '-' . $types[$i]; } $res = Database::simpleQuery("SELECT ruleid, datafilter, subject, message FROM notification_rule WHERE type IN (:types)", ['types' => $types]); // Iterate over all matching filter rules foreach ($res as $rule) { if (empty($rule['message']) && empty($rule['subject'])) { error_log('Filter rule with empty subject and message'); continue; } $filters = json_decode($rule['datafilter'], true); $globalMatch = true; $values = []; // Iterate over all filter-paths of this rule foreach ($filters['list'] as $key => $filter) { $index = $filter['index'] ?? $key; $path = explode('/', $filter['path']); // Get all items from $data that match the path $items = self::get($path, $data); if (empty($items)) { // If empty, add an empty string to result, so != can match $items[] = ''; } // Iterate over matches in $data - can be multiple if path contains '*' foreach ($items as $item) { if ($item === null || is_array($item)) continue; $match = self::matches($item, $filter); if ($match === null) continue; // Combine if multiple matches $values[$index] = self::combine($values[$index] ?? [], $match); } if (!isset($values[$index])) { $globalMatch = false; break; } } if ($globalMatch) { self::fireEvent($rule, $values); } } } /** * Fire event for given rule, fill templates with data from $values */ private static function fireEvent(array $rule, array $values) { $ruleid = (int)$rule['ruleid']; $subject = self::fillTemplate($rule['subject'], $values); $message = self::fillTemplate($rule['message'], $values); $ids = Database::queryColumnArray("SELECT transportid FROM notification_rule_x_transport sfxb WHERE sfxb.ruleid = :ruleid", ['ruleid' => $ruleid]); $group = NotificationTransport::newGroup(...$ids); $group->fire($subject, $message, $values); } /** * Get value at given path from assoc array. Calls itself recursively until path * is just one element. Supports special '*' path element, which will return all * items at the current level. For this reason, the return value is always an array. * This function is "hacky", as it tries to figure out whether the current key is * 1) the last path element and 2) matches a known column from the machines array. * If there exists no such key at the current level, it will be checked whether * machineuuid (preferred) or clientip exist at the current level, and if so, they * will be used to query the missing data from the database. * * @param array $path array of all the path elements * @param array $data data to wade through, first element of $path should be in it * @return array all the matched values */ private static function get(array $path, array &$data): array { if (empty($path)) return []; $pathElement = array_shift($path); // Get everything on this level if ($pathElement === '*') { $return = []; if (empty($path)) { // End, everything needs to be primitive types foreach ($data as $elem) { if (!is_array($elem)) { $return[] = $elem; } } } else { // Expected to go deeper foreach ($data as $elem) { if (is_array($elem)) { $return = array_merge($return, self::get($path, $elem)); } } } return $return; } if (!array_key_exists($pathElement, $data) && (isset($data['clientip']) || isset($data['machineuuid']))) { // An unknown key was requested, but we have clientip or machineuuid.... if (in_array($pathElement, self::MACHINE_COLUMNS) || !isset($data['machineuuid'])) { // Key matches a column from machine table, OR we don't have machineuuid but clientip // try to fetch it. Second condition is in case we have a HW_QUERIES virtual column. if ($pathElement !== 'machineuuid' && isset($data['machineuuid'])) { $row = Database::queryFirst("SELECT " . implode(',', self::MACHINE_COLUMNS) . " FROM machine WHERE machineuuid = :uuid", ['uuid' => $data['machineuuid']]); } elseif ($pathElement !== 'clientip' && isset($data['clientip'])) { $row = Database::queryFirst("SELECT " . implode(',', self::MACHINE_COLUMNS) . " FROM machine WHERE clientip = :ip ORDER BY lastseen DESC LIMIT 1", ['ip' => $data['clientip']]); } else { $row = false; } if ($row !== false) { $data += $row; } } if (isset($data['machineuuid']) && isset(self::HW_QUERIES[$pathElement]) && Module::isAvailable('statistics')) { // Key matches a predefined hwinfo property, resolve.... $q = new HardwareQuery(self::HW_QUERIES[$pathElement][0], $data['machineuuid']); $q->addColumn(self::HW_QUERIES[$pathElement][2], self::HW_QUERIES[$pathElement][1]); $res = $q->query(); if ($res !== false) { foreach ($res as $row) { $data[$pathElement][] = $row[self::HW_QUERIES[$pathElement][1]]; } } } } if (!array_key_exists($pathElement, $data)) return []; if (empty($path) && !is_array($data[$pathElement])) return [$data[$pathElement]]; if (empty($path) && ArrayUtil::isOnlyPrimitiveTypes($data[$pathElement])) return $data[$pathElement]; if (is_array($data[$pathElement])) return self::get($path, $data[$pathElement]); return []; // No match } /** * @param string $item item to match, string or number as string * @param array $filter filter struct [op, arg, result] * @return ?array null if op doesn't match, processed result otherwise */ private static function matches(string $item, array $filter): ?array { $ok = false; switch ($filter['op']) { case '*': $ok = true; break; case '>': $ok = $item > $filter['arg']; break; case '>=': $ok = $item >= $filter['arg']; break; case '<': $ok = $item < $filter['arg']; break; case '<=': $ok = $item <= $filter['arg']; break; case '=': $ok = $item == $filter['arg']; break; case '!=': $ok = $item != $filter['arg']; break; case 'regex': $ok = (bool)preg_match($filter['arg'], $item, $out); break; default: EventLog::warning("Invalid filter OP: {$filter['op']}"); } if (!$ok) // No match return null; // Fake $out array for simple matches if ($filter['op'] !== 'regex') { $out = [1 => $item]; } return $out ?? []; } private static function fillTemplate(string $template, array $values): string { return preg_replace_callback('/%([0-9]+)(?::([0-9]+|[a-z][a-z0-9_]*))?\.?([a-z]*)%/i', function($m) use ($values) { if (!isset($values[$m[1]])) return ''; if (($m[2] ?? '') === '') { $m[2] = 1; } if (!isset($values[$m[1]][$m[2]])) return ''; $v = $values[$m[1]][$m[2]]; $shift = 0; switch ($m[3]) { case 'gb': $shift++; // fallthrough case 'mb': $shift++; // fallthrough case 'kb': $shift++; // fallthrough case 'b': return Util::readableFileSize((int)$v, -1, $shift); case 'ts': return Util::prettyTime((int)$v); case 'd': return Util::formatDuration((int)$v); case 'L': if (Module::isAvailable('locations')) return Location::getName((int)$v) ?: '-'; break; case '': break; default: $v .= '(unknown suffix ' . $m[3] . ')'; } return $v; }, $template); } private static function combine(array $a, array $b): array { foreach ($b as $k => $v) { if (isset($a[$k])) { $a[$k] .= ', ' . $v; } else { $a[$k] = $v; } } return $a; } }