summaryrefslogblamecommitdiffstats
path: root/modules-available/eventlog/inc/filterruleprocessor.inc.php
blob: f4a84ce1531dab930f329c0433e7f48dfcf36726 (plain) (tree)







































































































































































































































































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

}