summaryrefslogtreecommitdiffstats
path: root/modules-available/eventlog/inc
diff options
context:
space:
mode:
Diffstat (limited to 'modules-available/eventlog/inc')
-rw-r--r--modules-available/eventlog/inc/filterruleprocessor.inc.php350
-rw-r--r--modules-available/eventlog/inc/notificationtransport.inc.php279
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