summaryrefslogblamecommitdiffstats
path: root/modules-available/eventlog/inc/filterruleprocessor.inc.php
blob: dd0160d749701416775c08e3d7700f0ea81a8dc9 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11





                                                                                                                          
                                                                                                                     



                                                                                                                             
                                                          
                            










































                                                                                                    


                                                                               

          
















                                                                                                                      
                                                                                 





                                                                                                                        




                                                                                              










































                                                                                                           
                                                                              
                                                         




















































                                                                                                       

















                                                                                                        
 
                                                          

                                                                                                


                                                                                                                     









                                                                                                                                                    

                                                      

                                                       


                                                                                                                        
                                                                                                                    

                                                     

                                                                                                                

                                         

                         
 



                                                                   

                                                                                         
                                                  








                                                                              
                                                                            



































                                                                                
                                  



                                                                                     
                                                                                                                                   

                                                                            


                                                   






                                                                                                      
                                              

                                         
                                              

                                         
                                              
                                 
                                                                                   
                                  
                                                                 
                                 
                                                                     
                                 


                                                                                 




















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

}