<?php
class FilterRuleProcessor
{
const MACHINE_COLUMNS = ['machineuuid', 'clientip', 'locationid', 'macaddr', 'firstseen', 'lastseen', 'logintime',
'lastboot', 'state', 'realcores', 'mbram', 'kvmstate', 'cpumodel', 'systemmodel', 'id44mb',
'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'];
/*
* 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.
*/
/**
* @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;
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) {
$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 === '*') {
if (!is_array($data))
return [];
$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'])) && in_array($pathElement, self::MACHINE_COLUMNS)) {
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) {
error_log('Additional client data fetched on the fly');
$data += $row;
}
}
if (!array_key_exists($pathElement, $data))
return [];
if (empty($path) && !is_array($data[$pathElement]))
return [$data[$pathElement]];
if (!empty($path) && 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)
{
$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]*)%/', function($m) use ($values) {
if (!isset($values[$m[1]]))
return '<invalid row index #' . $m[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++;
case 'mb':
$shift++;
case 'kb':
$shift++;
case 'b':
return Util::readableFileSize($v, -1, $shift);
case 'ts':
return Util::prettyTime($v);
case 'd':
return Util::formatDuration($v);
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;
}
}