', '<=', '>=']; const OP_STRCMP = ['~', '!~', '=', '!=']; const OP_NOMINAL = ['=', '!=']; const OP_LOCATIONS = ['~', '=', '!=']; const OP_FUZZY_ORDINAL = ['=', '!=', '~', '!~', '<', '>', '<=', '>=']; /** * @var StatisticsFilter[] */ public static $columns; /* * Class instance stuff */ /** * @var string|null db-based sort column for this field, null if not sortable */ public $column; /** * @var string[] valid operators for this filter */ public $ops; /** * @var string placeholder for input field */ public $placeholder; public function __construct($column, array $ops, string $placeholder = '') { $this->column = $column; $this->ops = $ops; $this->placeholder = $placeholder; } public function type(): string { return ($this->ops === self::OP_ORDINAL || $this->ops === self::OP_FUZZY_ORDINAL) ? 'int' : 'string'; } /** * Needed for joins with the hardware tables, to use the HardwareQueryColumn afterwards. * The HardwareQuery class should probably be extended/rewritten to be more versatile in * this regard. */ public static function addHardwareJoin(array &$args, array &$joins, string $hwtype = null): string { $joins['mxhw'] = ' INNER JOIN machine_x_hw mxhw ON (mxhw.disconnecttime = 0 AND mxhw.machineuuid = m.machineuuid)'; $key = self::getNewKey('foo'); $shw = self::getNewKey('shw'); if ($hwtype === null) { $joins[] = " INNER JOIN statistic_hw $shw ON (mxhw.hwid = {$shw}.hwid)"; } else { $joins[] = " INNER JOIN statistic_hw $shw ON (mxhw.hwid = {$shw}.hwid AND {$shw}.hwtype = :$key)"; $args[$key] = $hwtype; } return $shw; } /** * To be called by DatabaseFilter::whereClause() when building actual query. * @param string $operator operator to use * @param string[]|string $argument argument to compare against * @param string[] $args assoc array to add parametrized version of $argument to * @param string[] $joins any optional joins can be added to this array * @return string where clause */ public abstract function whereClause(string $operator, $argument, array &$args, array &$joins): string; /** * Called to get an instance of DatabaseFilter that binds the given $op and $argument to this filter. * @param string[]|string $argument */ public function bind(string $op, $argument): DatabaseFilter { return new DatabaseFilter($this, $op, $argument); } /** * Check if given $operator is valid for this filter. Throws error and halts if not. * @return void */ public final function validateOperator(string $operator) { if (empty($this->ops)) return; if (!in_array($operator, $this->ops)) { // Yes keep $this in this call, get_class() !== get_class($this) ErrorHandler::traceError("Invalid op '$operator' for " . get_class($this) . '::' . $this->column); } } /* * Static/Helpers */ public static function findBestValue($array, $value, $up) { $best = 0; for ($i = 0; $i < count($array); ++$i) { if (abs($array[$i] - $value) < abs($array[$best] - $value)) { $best = $i; } } if (!$up && $best === 0) { return $array[0]; } if ($up && $best + 1 === count($array)) { return $array[$best]; } if ($up) { return ($array[$best] + $array[$best + 1]) / 2; } return ($array[$best] + $array[$best - 1]) / 2; } public static function getNewKey($colname): string { return $colname . '_' . (self::$keyCounter++); } /** * @return DatabaseFilter[] */ public static function parseQuery(): array { // Get current settings from GET $ops = Request::get('op', [], 'array'); $currentValues = ArrayUtil::mergeByKey([ 'filter' => Request::get('filter', [], 'array'), 'op' => $ops, 'argument' => Request::get('arg', [], 'array'), ]); if (Request::get('show') === false && empty($ops)) { $currentValues['lastseen'] = [ 'filter' => true, 'op' => '>', 'argument' => gmdate('Y-m-d', strtotime('-30 day')), ]; } $filters = []; foreach ($currentValues as $filterType => $data) { if (!$data['filter']) continue; $operator = $data['op']; $argument = $data['argument']; if (array_key_exists($filterType, self::$columns)) { $filters[$filterType] = self::$columns[$filterType]->bind($operator, $argument); } else { Message::addError('invalid-filter-key', $filterType); } } return $filters; } public static function renderFilterBox(string $show, StatisticsFilterSet $filterSet): void { // Build location list, with permissions if (Module::isAvailable('locations')) { self::$columns['location']->filterLocations($filterSet->getAllowedLocations()); } // Build column array for rendering $columns = []; $showCount = 0; foreach (self::$columns as $key => $filter) { $col = [ 'key' => $key, 'name' => Dictionary::translateFile('filters', $key), 'placeholder' => $filter->placeholder, ]; $bind = $filterSet->hasFilterKey($key); if ($filter->type() === 'int') { $col['input'] = 'number'; } elseif ($filter->type() === 'string') { $col['input'] = 'text'; } elseif ($filter->type() === 'date') { $col['input'] = 'text'; $col['inputclass'] = 'is-date'; } elseif ($filter->type() === 'enum') { $col['enum'] = true; /** @var EnumStatisticsFilter $filter */ $col['values'] = $filter->values; if ($bind !== null) { // Current value from GET foreach ($col['values'] as &$value) { if ($value['key'] == $bind->argument) { $value['selected'] = 'selected'; } } } } // current value from GET if ($bind !== null) { $col['currentvalue'] = $bind->argument; $col['checked'] = 'checked'; $showCount++; } elseif (!isset($col['show']) || !$col['show']) { $col['collapse'] = 'collapse'; } $col['op'] = $filter->ops; foreach ($col['op'] as &$value) { $value = ['op' => $value]; if ($bind !== null && $bind->op === $value['op']) { $value['selected'] = 'selected'; } } $columns[$key] = $col; } if ($showCount < 2) { unset($columns['clientip']['collapse']); } if ($showCount < 1) { unset($columns['machineuuid']['collapse']); } $data = array( 'show' => $show, 'columns' => array_values($columns), $show . 'ButtonClass' => 'active', ); Permission::addGlobalTags($data['perms'], null, ['view.summary', 'view.list']); Render::addTemplate('filterbox', $data); } public static function initConstants() { self::$columns = [ 'clientip' => new IpStatisticsFilter(), 'hostname' => new SimpleStatisticsFilter('hostname', self::OP_STRCMP, 'pc.fqdn.example.com'), 'machineuuid' => new SimpleStatisticsFilter('machineuuid', self::OP_STRCMP, '88888888-4444-4444-121212121212'), 'macaddr' => new MacAddressStatisticsFilter(), 'firstseen' => new DateStatisticsFilter('firstseen', '2020-10-15 14:00'), 'lastseen' => new DateStatisticsFilter('lastseen', '2020-10-15 14:00'), 'lastboot' => new DateStatisticsFilter('lastboot', '2020-10-15 14:00'), 'runtime' => new RuntimeStatisticsFilter(), 'realcores' => new SimpleStatisticsFilter('realcores', self::OP_ORDINAL, ''), 'systemmodel' => new SystemModelStatisticsFilter(), 'cpumodel' => new SimpleStatisticsFilter('cpumodel', self::OP_STRCMP, 'Pentium Pro 200 MHz'), 'hddgb' => new PartitionGbStatisticsFilter('id44mb'), 'persistentgb' => new PartitionGbStatisticsFilter('id45mb'), 'gbram' => new RamGbStatisticsFilter(), 'kvmstate' => new EnumStatisticsFilter('kvmstate', ['ENABLED', 'DISABLED', 'UNSUPPORTED']), 'badsectors' => new SimpleStatisticsFilter('badsectors', self::OP_ORDINAL, ''), 'currentuser' => new SimpleStatisticsFilter('currentuser', self::OP_STRCMP, 'login'), 'state' => new StateStatisticsFilter(), 'live_swapfree' => new SimpleStatisticsFilter('live_swapfree', self::OP_ORDINAL, 'MiB'), 'live_memfree' => new SimpleStatisticsFilter('live_memfree', self::OP_ORDINAL, 'MiB'), 'live_tmpfree' => new SimpleStatisticsFilter('live_tmpfree', self::OP_ORDINAL, 'MiB'), 'live_id45free' => new SimpleNotZeroStatisticsFilter('live_id45free', self::OP_ORDINAL, 'MiB'), 'standbycrash' => new StandbyCrashStatisticsFilter(), 'pcidev' => new PciDeviceStatisticsFilter(), 'nicspeed' => new NicSpeedStatisticsFilter(), 'hddrpm' => new HddRpmStatisticsFilter(), //'anydev' => new AnyHardwarePropStatisticsFilter(), ]; if (Module::isAvailable('locations')) { self::$columns['location'] = new LocationStatisticsFilter(); } } } class SimpleStatisticsFilter extends StatisticsFilter { public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $addendum = ''; $key = self::getNewKey($this->column); $args[$key] = $argument; if (is_array($argument)) { if ($operator[0] === '!') { $op = 'NOT IN'; } else { $op = 'IN'; } } else { if ($operator === '~' || $operator === '!~') { $args[$key] = str_replace(array('=', '_', '%', '*', '?'), array('==', '=_', '=%', '%', '_'), $args[$key]); $addendum = " ESCAPE '='"; } $op = $operator; if ($operator === '~') { $op = 'LIKE'; } elseif ($operator === '!~') { $op = 'NOT LIKE'; } } return 'm.' . $this->column . ' ' . $op . ' (:' . $key . ') ' . $addendum; } } class SimpleNotZeroStatisticsFilter extends SimpleStatisticsFilter { public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $str = parent::whereClause($operator, $argument, $args, $joins); if ((int)$argument !== 0 || $operator !== '=') { $str = "($str AND {$this->column} != 0)"; } return $str; } } class EnumStatisticsFilter extends SimpleStatisticsFilter { public $values; public function __construct(string $column, array $values, array $ops = self::OP_NOMINAL) { parent::__construct($column, $ops, ''); if (isset($values[0])) { if (!is_array($values[0])) { $values = array_map(function($e) { return [ 'key' => $e, 'value' => $e, ]; }, $values); } } else { $values = array_map(function($v, $k) { return [ 'key' => $k, 'value' => $v, ]; }, $values, array_keys($values)); } $this->values = $values; } public function type(): string { return 'enum'; } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { if ($this->validateArgument()) { $keys = ArrayUtil::flattenByKey($this->values, 'key'); if (is_array($argument)) { $ok = true; foreach ($argument as $e) { if (!in_array($e, $keys)) { $ok = false; } } } else { $ok = in_array($argument, $keys); } if (!$ok) { Message::addError('invalid-enum-item', $this->column, $argument); return '0'; } } return parent::whereClause($operator, $argument, $args, $joins); } protected function validateArgument(): bool { return true; } } class StandbyCrashStatisticsFilter extends EnumStatisticsFilter { public function __construct() { parent::__construct('standbysem', ['NONE', 'MANY']); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { if ($argument === 'NONE') { $argument = 0; } else { // MANY $argument = 3; $operator = $operator === '=' ? '>' : '<='; } return parent::whereClause($operator, $argument, $args, $joins); } protected function validateArgument(): bool { return false; } } class DateStatisticsFilter extends StatisticsFilter { public function __construct(string $column, string $placeholder) { parent::__construct($column, self::OP_ORDINAL, $placeholder); } public function type(): string { return 'date'; } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $key = self::getNewKey($this->column); if (!preg_match('/^(?\d{4}-\d{2}-\d{2})(\s+(?\d{1,2})(:(?\d{2})(:\d+)?)?)?$/', $argument, $out)) { Message::addError('invalid-date-format', $argument); return '0'; } if (isset($out['m'])) { $span = 'minute'; } elseif (isset($out['h'])) { $span = 'hour'; $argument .= ':00'; } else { $span = 'day'; } $args[$key] = strtotime($argument); if ($operator === '=' || $operator === '!=') { $key2 = self::getNewKey($this->column); $args[$key2] = strtotime(' +1 ' . $span, $args[$key]); return ($operator === '=' ? '' : 'NOT ') . 'm.' . $this->column . " BETWEEN :$key AND :$key2"; } if ($operator === '>' || $operator === '<=') { $args[$key] = strtotime('+1 ' . $span . ' -1 second', $args[$key]); } return 'm.' . $this->column . ' ' . $operator . ' :' . $key; } } class RuntimeStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct('lastboot', self::OP_ORDINAL); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $upper = time() - (int)$argument * 3600; $lower = $upper - 3600; $common = "state IN ('OCCUPIED', 'IDLE', 'STANDBY') AND"; if ($operator == '<') { // These are inverted (uptime vs lastboot) return "$common lastboot > $upper"; } elseif ($operator == '<=') { return "$common lastboot > $lower"; } elseif ($operator == '>') { return "$common lastboot < $lower"; } elseif ($operator == '>=') { return "$common lastboot < $upper"; } elseif ($operator == '=') { return "$common (lastboot BETWEEN $lower AND $upper)"; } // != return "$common (lastboot NOT BETWEEN $lower AND > $upper)"; } } abstract class GbToMbRangeStatisticsFilter extends StatisticsFilter { protected function rangeClause(string $operator, $argument, array $fuzzyVals): string { if ($operator === '~' || $operator === '!~') { $lower = (int)floor(StatisticsFilter::findBestValue($fuzzyVals, (int)$argument, false) * 1024 - 500); $upper = (int)ceil(StatisticsFilter::findBestValue($fuzzyVals, (int)$argument, true) * 1024 + 100); $operator = str_replace('~', '=', $operator); } else { $lower = round($argument * 1024 - 500); $upper = round($argument * 1024 + 1023); } if ($operator === '=') return " {$this->column} BETWEEN $lower AND $upper"; if ($operator === '!=') return " {$this->column} NOT BETWEEN $lower AND $upper"; if ($operator === '<') return " {$this->column} < $lower"; if ($operator === '<=') return " {$this->column} <= $upper"; if ($operator === '>') return " {$this->column} > $upper"; return " {$this->column} >= $lower"; // >= } } class RamGbStatisticsFilter extends GbToMbRangeStatisticsFilter { public function __construct() { parent::__construct('mbram', self::OP_FUZZY_ORDINAL, 'GiB'); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { return parent::rangeClause($operator, $argument, self::SIZE_RAM); } } class PartitionGbStatisticsFilter extends GbToMbRangeStatisticsFilter { public function __construct(string $column) { parent::__construct($column, self::OP_FUZZY_ORDINAL, 'GiB'); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { return parent::rangeClause($operator, $argument, self::SIZE_PARTITION); } } class StateStatisticsFilter extends EnumStatisticsFilter { public function __construct() { parent::__construct('state', ['on', 'off', 'idle', 'occupied', 'standby']); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $map = [ 'on' => ['IDLE', 'OCCUPIED'], 'off' => ['OFFLINE'], 'idle' => ['IDLE'], 'occupied' => ['OCCUPIED'], 'standby' => ['STANDBY'] ]; $neg = $operator === '!=' ? 'NOT ' : ''; if (array_key_exists($argument, $map)) { $key = StatisticsFilter::getNewKey($this->column); $args[$key] = $map[$argument]; return " m.state $neg IN ( :$key ) "; } Message::addError('invalid-filter-argument', 'state', $argument); return ' 1'; } } class LocationStatisticsFilter extends EnumStatisticsFilter { public function __construct() { $locs = []; foreach (Location::getLocations(-1, 0, true) as $loc) { $locs[] = [ 'key' => $loc['locationid'], 'value' => $loc['locationpad'] . ' ' . $loc['locationname'], ]; } parent::__construct('locationid', $locs, self::OP_LOCATIONS); } public function type(): string { return 'enum'; } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $recursive = (substr($operator, -1) === '~'); $operator = str_replace('~', '=', $operator); if ($recursive && is_array($argument)) { ErrorHandler::traceError('Cannot use ~ operator for location with array'); } if ($recursive) { $argument = array_keys(Location::getRecursiveFlat($argument)); } elseif ($argument == 0) { return 'locationid IS ' . ($operator === '!=' ? 'NOT' : '') . ' NULL'; } return parent::whereClause($operator, $argument, $args, $joins); } public function filterLocations($list) { if ($list === false || in_array(0, $list)) return; foreach ($this->values as &$loc) { if (!in_array($loc['key'], $list)) { $loc['disabled'] = 'disabled'; } } } } class IpStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct('clientip', self::OP_NOMINAL, '1.2.3.4, 1.2.3.*, 1.2.3/24'); } public function whereClause(string $operator, $argument, array &$args, array &$joins) : string { $argument = strtolower(preg_replace('#[^0-9a-f.:/*]#i', '', $argument)); if (filter_var($argument, FILTER_VALIDATE_IP) !== false) { // Valid \o/ - do nothing to $argument } elseif (strpos($argument, '/') !== false) { // TODO: IPv6 CIDR $range = IpUtil::parseCidr($argument); if ($range === null) { Message::addError('invalid-cidr-notion', $argument); return '0'; } return 'INET_ATON(clientip) BETWEEN ' . $range['start'] . ' AND ' . $range['end']; } elseif (($num = substr_count($argument, ':')) !== 0 && $num <= 7) { // TODO: Probably valid IPv6, not yet in DB } elseif (($num = substr_count($argument, '.')) !== 0 && $num <= 3) { if (substr($argument, -1) === '.') { $argument .= '*'; } elseif ($num < 3) { $argument .= '.*'; } } else { Message::addError('invalid-ip-address', $argument); return '0'; } $operator = $operator[0] === '!' ? 'NOT LIKE' : 'LIKE'; return "clientip $operator '" . str_replace('*', '%', $argument) . "'"; } } class IsClientStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct(null, []); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { if ($argument) { $joins[] = ' LEFT JOIN runmode ON (m.machineuuid = runmode.machineuuid)'; return "(runmode.isclient <> 0 OR runmode.isclient IS NULL)"; } $joins[] = ' INNER JOIN runmode ON (m.machineuuid = runmode.machineuuid)'; return "runmode.isclient = 0"; } } class PciDeviceStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct(null, ['='], 'vvvv[:dddd][,cccc]'); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { // vendor[:device][,class] if (!preg_match('/^(?[0-9a-f]{4})(?::(?[0-9a-f]{4}))?(?:,(?[0-9a-f]{4}))?$/i', $argument, $out)) { Message::addError('invalid-pciid', $argument); return '0'; } $vendor = $out['v']; $device = $out['d'] ?? ''; $class = $out['c'] ?? ''; // basic join for hw_x_machine $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::PCI_DEVICE); $_ = []; $c = new HardwareQueryColumn(true, 'vendor'); $c->addCondition($operator, $vendor); $c->generate($joins, $_, $args, [], $shw); if (!empty($device)) { $c = new HardwareQueryColumn(true, 'device'); $c->addCondition($operator, $device); $c->generate($joins, $_, $args, [], $shw); } if (!empty($class)) { $c = new HardwareQueryColumn(true, 'class'); $c->addCondition($operator, $class); $c->generate($joins, $_, $args, [], $shw); } return '1'; } } class NicSpeedStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct(null, StatisticsFilter::OP_ORDINAL, 'MBit/s'); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::MAINBOARD); $_ = []; $c = new HardwareQueryColumn(false, 'nic-speed'); $c->addCondition($operator, $argument); $c->generate($joins, $_, $args, [], $shw); return '1'; } } class HddRpmStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct(null, StatisticsFilter::OP_ORDINAL, '7200'); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::HDD); $_ = []; $c = new HardwareQueryColumn(true, 'rotation_rate'); $c->addCondition($operator, $argument); $c->generate($joins, $_, $args, [], $shw); return '1'; } } class SystemModelStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct(null, StatisticsFilter::OP_STRCMP, 'PC-365 (IBM)'); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::DMI_SYSTEM); $_ = []; $manufacturer = null; $model = $argument; if (preg_match('/^(.*)\((.*)\)\s*$/', $model, $out)) { $manufacturer = trim($out[2]); $model = trim($out[1]); } $c = new HardwareQueryColumn(true, 'Product Name'); $c->addCondition($operator, $model); $c->generate($joins, $_, $args, [], $shw); if ($manufacturer !== null) { $c = new HardwareQueryColumn(true, 'Manufacturer'); $c->addCondition($operator, $manufacturer); $c->generate($joins, $_, $args, [], $shw); } return '1'; } } class MacAddressStatisticsFilter extends SimpleStatisticsFilter { public function __construct() { parent::__construct('macaddr', self::OP_STRCMP, '11-22-33-44-55-66'); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { // Allow just 12 hex digits, and convert ':' to '-', which we unfortunately settled on for the DB format if (preg_match('/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i', $argument, $out)) { $argument = $out[1] . '-' . $out[2] . '-' . $out[3] . '-' . $out[4] . '-' . $out[5] . '-' . $out[6]; } elseif (strpos($argument, ':') !== false) { $argument = str_replace(':', '-', $argument); } return parent::whereClause($operator, $argument, $args, $joins); } } class AnyHardwarePropStatisticsFilter extends StatisticsFilter { public function __construct() { parent::__construct(null, ['~']); } public function whereClause(string $operator, $argument, array &$args, array &$joins): string { $shw = StatisticsFilter::addHardwareJoin($args, $joins); $val = self::getNewKey('val'); $key1 = self::getNewKey('hw'); $joins[] = "LEFT JOIN statistic_hw_prop $key1 ON (`$key1`.`value` LIKE :$val AND `$key1`.hwid = `$shw`.hwid)"; $key2 = self::getNewKey('hw'); $joins[] = "LEFT JOIN machine_x_hw_prop $key2 ON (`$key2`.`value` LIKE :$val AND `$key2`.machinehwid = mxhw.machinehwid)"; $args[$val] = '%' . str_replace(['%', '*'], ['_', '%'], $argument) . '%'; return "((`$key1`.`value` IS NOT NULL) OR (`$key2`.`value` IS NOT NULL))"; } } class DatabaseFilter { /** @var StatisticsFilter */ private $inst; public $op; public $argument; /** * Called by StatisticsFilter::bind(). */ public function __construct(StatisticsFilter $inst, string $op, $argument) { $inst->validateOperator($op); $this->inst = $inst; $this->op = $op; $this->argument = $argument; } /** * Called from StatisticsFilterSet::makeFragments() to build the final query. */ public function whereClause(array &$args, array &$joins): string { return $this->inst->whereClause($this->op, $this->argument, $args, $joins); } public function isClass(string $what): bool { return get_class($this->inst) === $what; } public function getClass(): string { return get_class($this->inst); } } StatisticsFilter::initConstants();