<?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;
}
}