diff options
Diffstat (limited to 'modules-available/eventlog/inc')
-rw-r--r-- | modules-available/eventlog/inc/filterruleprocessor.inc.php | 350 | ||||
-rw-r--r-- | modules-available/eventlog/inc/notificationtransport.inc.php | 279 |
2 files changed, 629 insertions, 0 deletions
diff --git a/modules-available/eventlog/inc/filterruleprocessor.inc.php b/modules-available/eventlog/inc/filterruleprocessor.inc.php new file mode 100644 index 00000000..dd0160d7 --- /dev/null +++ b/modules-available/eventlog/inc/filterruleprocessor.inc.php @@ -0,0 +1,350 @@ +<?php + +class FilterRuleProcessor +{ + + const MACHINE_COLUMNS = ['machineuuid', 'clientip', 'locationid', 'macaddr', 'firstseen', 'lastseen', 'logintime', + 'lastboot', 'state', 'realcores', 'mbram', 'kvmstate', 'cpumodel', 'systemmodel', 'id44mb', 'id45mb', + 'live_memsize', 'live_tmpsize', 'live_swapsize', 'live_id45size', 'live_memfree', 'live_tmpfree', + 'live_swapfree', 'live_id45free', 'live_cpuload', 'live_cputemp', 'badsectors', 'hostname', 'currentrunmode', + 'currentsession', 'currentuser', 'notes', 'standbysem']; + + // <device-type>, <property>, <is_global_property> + 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 '<invalid row index #' . $m[1] . '>'; + if (($m[2] ?? '') === '') { + $m[2] = 1; + } + if (!isset($values[$m[1]][$m[2]])) + return '<invalid column index #' . $m[2] . ' for row #' . $m[1] . '>'; + $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; + } + +} diff --git a/modules-available/eventlog/inc/notificationtransport.inc.php b/modules-available/eventlog/inc/notificationtransport.inc.php new file mode 100644 index 00000000..499f6371 --- /dev/null +++ b/modules-available/eventlog/inc/notificationtransport.inc.php @@ -0,0 +1,279 @@ +<?php + +abstract class NotificationTransport +{ + + public static function getInstance(array $data) + { + switch ($data['type'] ?? '') { + case 'mail': + return new MailNotificationTransport($data); + case 'irc': + return new IrcNotificationTransport($data); + case 'http': + return new HttpNotificationTransport($data); + case 'group': + return new GroupNotificationTransport($data); + } + error_log('Invalid Notification Transport: ' . ($data['type'] ?? '(unset)')); + return null; + } + + public static function newGroup(int ...$ids): GroupNotificationTransport + { + return new GroupNotificationTransport(['group-list' => $ids]); + } + + public abstract function __construct(array $data); + + public abstract function toString(): string; + + public abstract function fire(string $subject, string $message, array $raw): bool; + + public abstract function isValid(): bool; + +} + +class MailNotificationTransport extends NotificationTransport +{ + + /** @var int */ + private $mailConfigId; + + /** @var int[] */ + private $userIds; + + /** @var string */ + private $extraMails; + + public function __construct(array $data) + { + $this->mailConfigId = (int)($data['mail-config-id'] ?? 0); + $this->userIds = array_map(function ($i) { return (int)$i; }, $data['mail-users'] ?? []); + $this->extraMails = (string)($data['mail-extra-mails'] ?? ''); + } + + public function toString(): string + { + static $mailList = null; + if ($mailList === null) { + $mailList = Database::queryIndexedList("SELECT configid, host, senderaddress, replyto + FROM mail_config"); + } + $str = 'Via: ' . ($mailList[$this->mailConfigId]['host'] ?? '<none>') + . ' as ' . ($mailList[$this->mailConfigId]['senderaddress'] ?? $mailList[$this->mailConfigId]['replyto'] ?? '<none>'); + if (!empty($this->userIds)) { + $str .= ', Users: ' . count($this->userIds); + } + if (!empty($this->extraMails)) { + $str .= ', External: ' . substr_count($this->extraMails, '@'); + } + return $str; + } + + public function fire(string $subject, string $message, array $raw): bool + { + if (!$this->isValid()) + return false; + $addrsOut = []; + if (preg_match_all('/[^@\s]+@[^@\s]+/', $this->extraMails, $out)) { + $addrsOut = $out[0]; + } + if (!empty($this->userIds)) { + $mails = Database::queryColumnArray("SELECT email + FROM user + WHERE userid IN (:users)", + ['users' => $this->userIds]); + foreach ($mails as $mail) { + if (preg_match('/^[^@\s]+@[^@\s]+$/', $mail)) { + $addrsOut[] = $mail; + } + } + } + if (empty($addrsOut)) + return false; + Mailer::queue($this->mailConfigId, $addrsOut, $subject, $message); + return true; + } + + public function isValid(): bool + { + if ($this->mailConfigId === 0) + return false; + $mailer = Mailer::instanceFromConfig($this->mailConfigId); + return $mailer !== null; + } +} + +class IrcNotificationTransport extends NotificationTransport +{ + + private $server; + + private $serverPasswort; + + private $target; + + private $nickName; + + public function __construct(array $data) + { + $this->server = $data['irc-server'] ?? ''; + $this->serverPasswort = $data['irc-server-password'] ?? ''; + $this->target = $data['irc-target'] ?? ''; + $this->nickName = $data['irc-nickname'] ?? 'BWLP-' . mt_rand(10000, 99999); + } + + public function toString(): string + { + return '(' . $this->server . '), ' . $this->nickName . ' @ ' . $this->target; + } + + public function fire(string $subject, string $message, array $raw): bool + { + if (!$this->isValid()) + return false; + return !Taskmanager::isFailed(Taskmanager::submit('IrcNotification', [ + 'serverAddress' => $this->server, + 'serverPassword' => $this->serverPasswort, + 'channel' => $this->target, + 'message' => preg_replace('/[\r\n]+\s*/', ' ', $message), + 'nickName' => $this->nickName, + ])); + } + + public function isValid(): bool + { + return !empty($this->server) && !empty($this->target); + } +} + +class HttpNotificationTransport extends NotificationTransport +{ + + /** @var string */ + private $uri; + + /** @var string */ + private $method; + + /** @var string */ + private $postField; + + /** @var string */ + private $postFormat; + + public function __construct(array $data) + { + $this->uri = $data['http-uri'] ?? ''; + $this->method = $data['http-method'] ?? 'POST'; + $this->postField = $data['http-post-field'] ?? 'message=%TEXT%&subject=%SUBJECT%'; + $this->postFormat = $data['http-post-format'] ?? 'FORM'; + } + + public function toString(): string + { + return $this->uri . ' (' . $this->method . ')'; + } + + public function fire(string $subject, string $message, array $raw): bool + { + if (!$this->isValid()) + return false; + $url = str_replace(['%TEXT%', '%SUBJECT%'], [urlencode($message), urlencode($subject)], $this->uri); + if ($this->method === 'POST') { + switch ($this->postFormat) { + case 'FORM': + $body = str_replace(['%TEXT%', '%SUBJECT%'], [urlencode($message), urlencode($subject)], $this->postField); + $ctype = 'application/x-www-form-urlencoded'; + break; + case 'JSON': + $body = str_replace(['%TEXT%', '%SUBJECT%'], [json_encode($message), + json_encode($subject)], $this->postField); + $ctype = 'application/json'; + break; + default: + $out = []; + foreach ($raw as $k1 => $a) { + foreach ($a as $k2 => $v) { + $out["$k1.$k2"] = $v; + } + } + $body = json_encode($out); + $ctype = 'application/json'; + } + } else { + $body = null; + $ctype = null; + } + return !Taskmanager::isFailed(Taskmanager::submit('HttpRequest', [ + 'url' => $url, + 'postData' => $body, + 'contentType' => $ctype, + ])); + } + + public function isValid(): bool + { + return !empty($this->uri); + } +} + +class GroupNotificationTransport extends NotificationTransport +{ + + /** @var int[] list of contained notification transports */ + private $list; + + public function __construct(array $data) + { + $this->list = array_map(function ($i) { return (int)$i; }, $data['group-list'] ?? []); + } + + public function toString(): string + { + static $groupList = null; + if ($groupList === null) { + $groupList = Database::queryKeyValueList("SELECT transportid, title FROM notification_backend"); + } + $out = array_map(function ($i) use ($groupList) { return $groupList[$i] ?? "#$i"; }, $this->list); + return implode(', ', $out); + } + + public function fire(string $subject, string $message, array $raw): bool + { + // This is static, so recursing into groups will keep track of ones we already saw + static $done = false; + $first = ($done === false); + if ($first) { // Non-recursive call, init list + $done = []; + } + $list = array_diff($this->list, $done); + if (!empty($list)) { + $done = array_merge($done, $list); + $res = Database::simpleQuery("SELECT data FROM notification_backend WHERE transportid IN (:ids)", + ['ids' => $list]); + foreach ($res as $row) { + $data = json_decode($row['data'], true); + if (is_array($data)) { + $inst = NotificationTransport::getInstance($data); + if ($inst !== null) { + $inst->fire($subject, $message, $raw); + } + } + } + } + if ($first) { + $done = false; // Outer-most call, reset + } + return true; + } + + public function isValid(): bool + { + // Do we really care about empty groups? They might be pointless, but not really invalid + // We could consider groups containing invalid IDs as invalid, but that would mean that we + // potentially ignore all the other existing IDs in this group, as it would never fire + return true; + } +}
\ No newline at end of file |