mirror of
https://github.com/itflow-org/itflow
synced 2026-11-14 08:27:24 +00:00
1600 lines
75 KiB
PHP
1600 lines
75 KiB
PHP
<?php
|
|
|
|
/*
|
|
* RMM Providers - devices from an RMM as ITFlow assets (Admin > Integrations > RMM Providers).
|
|
*
|
|
* Each provider (rmm_providers) is one RMM ITFlow reads from: its kind - which RMM it is - the
|
|
* name it goes by here, its addresses and its key. Every kind looks the same to the rest of
|
|
* ITFlow. Its own file says what the kind is and turns the RMM's answers into the shapes
|
|
* below (functions/rmm_tactical.php for Tactical RMM); this file does the rest. Adding an RMM
|
|
* is a file like that and a line in rmmProviderKinds().
|
|
*
|
|
* A provider's clients (rmm_clients) are linked to ITFlow clients by hand on its Clients page -
|
|
* a client with the same name is suggested. Only a linked client's devices come in: every 15
|
|
* minutes (cron/rmm_sync.php, or Sync now) each device (rmm_devices) becomes an asset, or is
|
|
* matched to the asset it already is - same serial number, or the same name when only one
|
|
* asset has it - and its asset is kept up to date.
|
|
*
|
|
* What a device is - its name, type, make and model, serial, OS, IP, CPU, storage, and its
|
|
* location from its site in the RMM - follows the RMM, field by field, until someone changes
|
|
* that field in ITFlow: rmm_devices.rmm_device_synced holds what the sync last wrote, and a
|
|
* field that no longer holds it was changed by hand and is left alone from then on. A blank
|
|
* from the RMM never blanks a field. Matching an asset that was already there only fills in
|
|
* what it hasn't got.
|
|
*
|
|
* What a device is doing - online, offline or overdue, last seen, who is signed in, a reboot
|
|
* or patches waiting, failing checks - lives on its rmm_devices row and shows on the asset.
|
|
* A device the RMM no longer has keeps its asset, marked as gone from it. An asset deleted in
|
|
* ITFlow isn't brought back by the next sync - its row stays, with no asset, until Bring back
|
|
* on the Clients page lets the sync make it again.
|
|
*/
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ The kinds
|
|
*
|
|
* A kind is an array:
|
|
* name, short - "Tactical RMM", and "Tactical" for "Gone from Tactical"
|
|
* icon, about - its icon, and a line on what it brings in
|
|
* docs_url - where its API keys are explained
|
|
* fields - the connection's fields it uses, of api_url, web_url, api_key and
|
|
* api_secret (the rmm_provider_ columns): each with label, icon, type (url
|
|
* or secret), required, placeholder, help and needed (said when it's empty)
|
|
* hints - added to what rmmHttp() says went wrong: auth (401/403), not_found
|
|
* (404) and not_json (an answer that isn't the API's)
|
|
* links - a device's pages in the RMM that rmmDeviceUrls() gives, key => label:
|
|
* open, and control (Take Control) and background where it has them
|
|
* clients - fn(provider): ['ok', 'clients' => [[id, name, sites, devices]], 'error']
|
|
* devices - fn(provider, client's id there): ['ok', 'devices' => [device], 'error'],
|
|
* each device as rmmDeviceSave() takes it
|
|
* dashboard - fn(provider): its web address, '' when it has none
|
|
* device_urls - fn(provider, device's id there): key of links => address
|
|
* alerts, alert - optional, for alerts opening tickets: fn(provider): ['ok', 'alerts' =>
|
|
* the open ones, 'error'], and fn(provider, alert's id there): ['ok',
|
|
* 'alert' => it, or null when the RMM no longer has it, 'error'] - each
|
|
* alert as described under Alerts below
|
|
*/
|
|
|
|
/** The RMMs ITFlow can bring devices in from - kind => what it is. */
|
|
function rmmProviderKinds(): array
|
|
{
|
|
return [
|
|
'tactical' => rmmTacticalKind(),
|
|
];
|
|
}
|
|
|
|
/** A provider's kind - null when ITFlow doesn't know it. */
|
|
function rmmKind($provider): ?array
|
|
{
|
|
$kind = is_array($provider) ? (string) ($provider['rmm_provider_kind'] ?? '') : (string) $provider;
|
|
return rmmProviderKinds()[$kind] ?? null;
|
|
}
|
|
|
|
/** A provider's name, for the places it is spoken of - the kind's when it hasn't one. */
|
|
function rmmProviderName(array $provider): string
|
|
{
|
|
$name = trim((string) ($provider['rmm_provider_name'] ?? ''));
|
|
return $name !== '' ? $name : (rmmKind($provider)['name'] ?? 'RMM');
|
|
}
|
|
|
|
/** Whether the RMM tables are there - they arrive with the 3.6.7 database update. */
|
|
function rmmTablesReady(): bool
|
|
{
|
|
global $mysqli;
|
|
static $ready = null;
|
|
if ($ready === null) {
|
|
try {
|
|
mysqli_query($mysqli, "SELECT 1 FROM rmm_devices LIMIT 1");
|
|
$ready = true;
|
|
} catch (Throwable $e) {
|
|
$ready = false;
|
|
}
|
|
}
|
|
return $ready;
|
|
}
|
|
|
|
/** One provider by id - raw, or null. */
|
|
function rmmProviderRow($provider_id): ?array
|
|
{
|
|
global $mysqli;
|
|
$provider_id = intval($provider_id);
|
|
if (!$provider_id || !rmmTablesReady()) {
|
|
return null;
|
|
}
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM rmm_providers WHERE rmm_provider_id = $provider_id LIMIT 1"));
|
|
return $row ?: null;
|
|
}
|
|
|
|
/** Every provider set up, on or off - by name. Read once a request. */
|
|
function rmmProviders(bool $fresh = false): array
|
|
{
|
|
global $mysqli;
|
|
static $providers = null;
|
|
if ($providers === null || $fresh) {
|
|
$providers = [];
|
|
if (rmmTablesReady()) {
|
|
$sql = mysqli_query($mysqli, "SELECT * FROM rmm_providers ORDER BY rmm_provider_name ASC, rmm_provider_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$providers[] = $row;
|
|
}
|
|
}
|
|
}
|
|
return $providers;
|
|
}
|
|
|
|
/** Whether a provider can be asked - on, of a kind ITFlow knows, with what its kind needs. */
|
|
function rmmProviderUsable(?array $provider): bool
|
|
{
|
|
$kind = $provider ? rmmKind($provider) : null;
|
|
if (!$kind || intval($provider['rmm_provider_active']) !== 1) {
|
|
return false;
|
|
}
|
|
foreach ($kind['fields'] as $field => $about) {
|
|
if (!empty($about['required']) && trim((string) ($provider["rmm_provider_$field"] ?? '')) === '') {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** An address typed in, tidied - https:// in front when there is no scheme, no trailing slash. '' when it isn't a web address. */
|
|
function rmmUrlClean($url): string
|
|
{
|
|
$url = trim((string) $url);
|
|
if ($url === '' || preg_match('/\s/', $url)) {
|
|
return '';
|
|
}
|
|
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $url) && !preg_match('#^https?://#i', $url)) {
|
|
return '';
|
|
}
|
|
if (!preg_match('#^https?://#i', $url)) {
|
|
$url = 'https://' . $url;
|
|
}
|
|
$parts = parse_url($url);
|
|
if (!$parts || empty($parts['host']) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || isset($parts['user']) || isset($parts['query']) || isset($parts['fragment'])) {
|
|
return '';
|
|
}
|
|
return rtrim($url, '/');
|
|
}
|
|
|
|
/** A provider's dashboard - where Open in its RMM goes. '' when it hasn't one. */
|
|
function rmmDashboardUrl(array $provider): string
|
|
{
|
|
$kind = rmmKind($provider);
|
|
if ($kind && isset($kind['dashboard'])) {
|
|
return (string) call_user_func($kind['dashboard'], $provider);
|
|
}
|
|
return rmmUrlClean($provider['rmm_provider_web_url'] ?? '');
|
|
}
|
|
|
|
/** A device's pages in its RMM - links key => address. $row is its rmm_devices row with its provider's columns. */
|
|
function rmmDeviceUrls(array $row): array
|
|
{
|
|
$kind = rmmKind($row);
|
|
$remote_id = (string) ($row['rmm_device_remote_id'] ?? '');
|
|
if (!$kind || $remote_id === '' || !isset($kind['device_urls'])) {
|
|
return [];
|
|
}
|
|
return array_filter((array) call_user_func($kind['device_urls'], $row, $remote_id), 'strlen');
|
|
}
|
|
|
|
/**
|
|
* Ask an RMM's API - ['ok', 'code', 'json', 'error']: error is what to tell a person when it
|
|
* didn't work, with the kind's hints. The kind's file says where and how to sign in.
|
|
*/
|
|
function rmmHttp(array $provider, string $method, string $url, array $headers = [], ?array $body = null, int $timeout = 30): array
|
|
{
|
|
$name = rmmProviderName($provider);
|
|
$hints = rmmKind($provider)['hints'] ?? [];
|
|
$hint = function ($key) use ($hints) {
|
|
return isset($hints[$key]) ? ' - ' . $hints[$key] : '';
|
|
};
|
|
|
|
$ch = curl_init($url);
|
|
$options = [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
|
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json', 'Content-Type: application/json'], $headers),
|
|
CURLOPT_TIMEOUT => $timeout,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
CURLOPT_USERAGENT => 'ITFlow',
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
|
];
|
|
if ($body !== null) {
|
|
$options[CURLOPT_POSTFIELDS] = json_encode($body);
|
|
}
|
|
curl_setopt_array($ch, $options);
|
|
$raw = curl_exec($ch);
|
|
$transport = curl_error($ch);
|
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
$host = (string) parse_url($url, PHP_URL_HOST);
|
|
$path = (string) parse_url($url, PHP_URL_PATH);
|
|
if ($raw === false) {
|
|
$ssl = stripos($transport, 'SSL') !== false && stripos($url, 'https://') === 0 ? ' - if it isn\'t on https, put http:// in front of the address' : '';
|
|
return ['ok' => false, 'code' => 0, 'json' => null, 'error' => "Couldn't reach $host - $transport$ssl"];
|
|
}
|
|
$json = json_decode((string) $raw, true);
|
|
if ($code === 401 || $code === 403) {
|
|
return ['ok' => false, 'code' => $code, 'json' => $json, 'error' => "$name turned the key away (HTTP $code)" . ($hint('auth') ?: ' - check the key')];
|
|
}
|
|
if ($code >= 300 && $code < 400) {
|
|
return ['ok' => false, 'code' => $code, 'json' => null, 'error' => "$host sent ITFlow somewhere else (HTTP $code) - use the API's own address, with https if it has it."];
|
|
}
|
|
if ($code === 404) {
|
|
return ['ok' => false, 'code' => $code, 'json' => $json, 'error' => "Nothing at $path on $host (HTTP 404)" . $hint('not_found')];
|
|
}
|
|
if ($code < 200 || $code > 299) {
|
|
$detail = is_array($json) ? rmmText($json['detail'] ?? $json['error'] ?? $json['message'] ?? '', 300) : '';
|
|
return ['ok' => false, 'code' => $code, 'json' => $json, 'error' => "$name answered HTTP $code" . ($detail !== '' ? " - $detail" : '')];
|
|
}
|
|
if (!is_array($json)) {
|
|
return ['ok' => false, 'code' => $code, 'json' => null, 'error' => "$host answered, but not as $name's API" . $hint('not_json')];
|
|
}
|
|
return ['ok' => true, 'code' => $code, 'json' => $json, 'error' => ''];
|
|
}
|
|
|
|
/** A provider's clients, from its RMM - ['ok', 'clients' => [id, name, sites, devices], 'error']. */
|
|
function rmmRemoteClients(array $provider): array
|
|
{
|
|
$kind = rmmKind($provider);
|
|
if (!$kind) {
|
|
return ['ok' => false, 'clients' => [], 'error' => 'ITFlow doesn\'t know this kind of RMM.'];
|
|
}
|
|
return call_user_func($kind['clients'], $provider);
|
|
}
|
|
|
|
/** One client's devices, from its RMM - ['ok', 'devices', 'error']. */
|
|
function rmmRemoteDevices(array $provider, string $client_remote_id): array
|
|
{
|
|
$kind = rmmKind($provider);
|
|
if (!$kind) {
|
|
return ['ok' => false, 'devices' => [], 'error' => 'ITFlow doesn\'t know this kind of RMM.'];
|
|
}
|
|
return call_user_func($kind['devices'], $provider, $client_remote_id);
|
|
}
|
|
|
|
/** Try a provider - ['ok', 'text']: how many clients and devices it sees, or what went wrong. */
|
|
function rmmTest(array $provider): array
|
|
{
|
|
$answer = rmmRemoteClients($provider);
|
|
if (!$answer['ok']) {
|
|
return ['ok' => false, 'text' => $answer['error']];
|
|
}
|
|
$devices = array_sum(array_column($answer['clients'], 'devices'));
|
|
$clients = count($answer['clients']);
|
|
return ['ok' => true, 'text' => 'Connected - ' . rmmProviderName($provider) . ' has ' . number_format($clients) . ' client' . ($clients === 1 ? '' : 's')
|
|
. ' and ' . number_format($devices) . ' device' . ($devices === 1 ? '' : 's') . ' this key can see.'];
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ Reading what an RMM says
|
|
*/
|
|
|
|
/** An RMM's words for "it couldn't tell" - not worth keeping. */
|
|
function rmmIsPlaceholder($value): bool
|
|
{
|
|
$value = strtolower(trim((string) $value));
|
|
if ($value === '' || $value === '-' || $value === 'none' || $value === 'null' || $value === 'n/a') {
|
|
return true;
|
|
}
|
|
foreach (['unknown ', 'error getting', 'to be filled', 'default string', 'system serial number', 'not specified', 'not applicable', 'chassis serial', 'requires agent'] as $words) {
|
|
if (str_contains($value, $words)) {
|
|
return true;
|
|
}
|
|
}
|
|
return preg_match('/^(0+|1234567890|123456789|x+|\.+|o\.e\.m\.?)$/', $value) === 1;
|
|
}
|
|
|
|
/**
|
|
* Text from an RMM as ITFlow keeps it - what escapeSql() would store (no tags), trimmed and
|
|
* cut to the column. Kept this way before it is compared with what is stored, so a value is
|
|
* the same on both sides and isn't written again every sync.
|
|
*/
|
|
function rmmText($value, int $max = 255): string
|
|
{
|
|
$value = (string) (is_scalar($value) ? $value : '');
|
|
if ($value !== '' && !mb_check_encoding($value, 'UTF-8')) {
|
|
$value = mb_convert_encoding($value, 'UTF-8', 'Windows-1252');
|
|
}
|
|
return mb_substr(trim(preg_replace('/\s+/u', ' ', strip_tags($value))), 0, $max);
|
|
}
|
|
|
|
/** A serial number worth matching on - '' when it is blank or a placeholder. */
|
|
function rmmSerial($serial): string
|
|
{
|
|
$serial = rmmText($serial, 200);
|
|
return rmmIsPlaceholder($serial) ? '' : $serial;
|
|
}
|
|
|
|
/** The first address worth showing from a list - not a link-local or loopback one. */
|
|
function rmmFirstIp($local_ips): string
|
|
{
|
|
foreach (preg_split('/[\s,]+/', (string) $local_ips) as $ip) {
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && !str_starts_with($ip, '169.254.') && !str_starts_with($ip, '127.')) {
|
|
return $ip;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/** A time from an RMM (ISO 8601, or seconds since 1970) as one in ITFlow's timezone - null when there isn't one. */
|
|
function rmmTime($value): ?string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
try {
|
|
if (is_numeric($value)) {
|
|
$time = new DateTime('@' . intval($value));
|
|
} else {
|
|
$time = new DateTime((string) $value);
|
|
}
|
|
$time->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
|
return $time->format('Y-m-d H:i:s');
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** What kind of asset a device is - from whether the RMM watches it as a server, and its make and model. */
|
|
function rmmDeviceType(string $role, string $make, string $model): string
|
|
{
|
|
$both = strtolower("$make $model");
|
|
foreach (['vmware', 'virtual machine', 'virtualbox', 'qemu', 'kvm', 'xen', 'bochs', 'parallels', 'proxmox', 'hvm domu', 'standard pc (', 'google compute', 'amazon', 'nutanix', 'openstack', 'cloud server'] as $word) {
|
|
if (str_contains($both, $word)) {
|
|
return 'Virtual Machine';
|
|
}
|
|
}
|
|
if ($role === 'server') {
|
|
return 'Server';
|
|
}
|
|
foreach (['latitude', 'thinkpad', 'thinkbook', 'ideapad', 'yoga', 'elitebook', 'probook', 'zbook', 'xps 1', 'xps 9', 'surface laptop', 'surface pro', 'surface book', 'surface go',
|
|
'macbook', 'zenbook', 'vivobook', 'expertbook', 'travelmate', 'aspire 3', 'aspire 5', 'aspire 7', 'swift', 'spin ', 'chromebook', 'dynabook', 'tecra', 'portege', 'satellite',
|
|
'toughbook', 'getac', 'galaxy book', 'spectre', 'envy x360', 'pavilion x360', 'dragonfly', 'legion 5', 'legion 7', 'precision 35', 'precision 55', 'precision 75',
|
|
'vostro 14', 'vostro 15', 'vostro 35', 'inspiron 13', 'inspiron 14', 'inspiron 15', 'inspiron 16', 'notebook', 'laptop'] as $word) {
|
|
if (str_contains($both, $word)) {
|
|
return 'Laptop';
|
|
}
|
|
}
|
|
return 'Desktop';
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ A device and its asset
|
|
*
|
|
* A device, as a kind's devices function gives it:
|
|
* remote_id - its id in the RMM (letters, digits, _ . : -)
|
|
* hostname, site, os, logged_user, public_ip, local_ips, agent_version - text, as rmmText() keeps it
|
|
* role - server or workstation, '' when the RMM doesn't say
|
|
* platform - windows, linux, darwin...
|
|
* status - online, offline or overdue, '' when it doesn't say
|
|
* last_seen_at, boot_at - from rmmTime(), or null
|
|
* needs_reboot, patches_pending, maintenance - 1 or 0
|
|
* checks_total, checks_failing - numbers
|
|
* asset - what its asset is: make, model, serial, os, ip, cpu,
|
|
* storage - '' where the RMM doesn't say - and type
|
|
* when the kind knows better than rmmDeviceType()
|
|
*/
|
|
|
|
/** The rmm_devices columns a device's facts go into, as they are to be saved. */
|
|
function rmmDeviceFacts(array $device): array
|
|
{
|
|
$text = function ($key, $max = 255) use ($device) {
|
|
return rmmText($device[$key] ?? '', $max);
|
|
};
|
|
$status = strtolower((string) ($device['status'] ?? ''));
|
|
$role = strtolower((string) ($device['role'] ?? ''));
|
|
return [
|
|
'hostname' => $text('hostname'),
|
|
'site' => $text('site'),
|
|
'role' => in_array($role, ['server', 'workstation'], true) ? $role : '',
|
|
'platform' => mb_substr(preg_replace('/[^a-z0-9_]/', '', strtolower((string) ($device['platform'] ?? ''))), 0, 20),
|
|
'os' => $text('os'),
|
|
'status' => in_array($status, ['online', 'offline', 'overdue'], true) ? $status : '',
|
|
'last_seen_at' => $device['last_seen_at'] ?? null,
|
|
'boot_at' => $device['boot_at'] ?? null,
|
|
'logged_user' => $text('logged_user'),
|
|
'public_ip' => $text('public_ip', 100),
|
|
'local_ips' => $text('local_ips', 500),
|
|
'needs_reboot' => !empty($device['needs_reboot']) ? 1 : 0,
|
|
'patches_pending' => !empty($device['patches_pending']) ? 1 : 0,
|
|
'checks_total' => intval($device['checks_total'] ?? 0),
|
|
'checks_failing' => intval($device['checks_failing'] ?? 0),
|
|
'maintenance' => !empty($device['maintenance']) ? 1 : 0,
|
|
'agent_version' => $text('agent_version', 50),
|
|
];
|
|
}
|
|
|
|
/** The location of an ITFlow client a site in the RMM is - the one with its name - or 0. */
|
|
function rmmSiteLocationId(int $client_id, string $site): int
|
|
{
|
|
global $mysqli;
|
|
static $locations = [];
|
|
if ($site === '') {
|
|
return 0;
|
|
}
|
|
if (!isset($locations[$client_id])) {
|
|
$locations[$client_id] = [];
|
|
$sql = mysqli_query($mysqli, "SELECT location_id, location_name FROM locations WHERE location_client_id = $client_id AND location_archived_at IS NULL");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$locations[$client_id][mb_strtolower(trim($row['location_name']))][] = intval($row['location_id']);
|
|
}
|
|
}
|
|
$found = $locations[$client_id][mb_strtolower(trim($site))] ?? [];
|
|
return count($found) === 1 ? $found[0] : 0;
|
|
}
|
|
|
|
/** What a device's asset is, by its RMM - field => value. '' (0 for the location) where the RMM doesn't say. */
|
|
function rmmDeviceAssetValues(array $device, array $facts, int $client_id): array
|
|
{
|
|
$asset = is_array($device['asset'] ?? null) ? $device['asset'] : [];
|
|
$value = function ($key, $max = 200) use ($asset) {
|
|
$text = rmmText($asset[$key] ?? '', $max);
|
|
return rmmIsPlaceholder($text) ? '' : $text;
|
|
};
|
|
$make = $value('make');
|
|
$model = $value('model');
|
|
$type = $value('type', 100);
|
|
return [
|
|
'name' => mb_substr($facts['hostname'], 0, 200),
|
|
'type' => $type !== '' ? $type : rmmDeviceType($facts['role'], $make, $model),
|
|
'make' => $make,
|
|
'model' => $model,
|
|
'serial' => rmmSerial($asset['serial'] ?? ''),
|
|
'os' => $value('os'),
|
|
'ip' => rmmFirstIp($asset['ip'] ?? ''),
|
|
'cpu' => $value('cpu', 500),
|
|
'storage' => $value('storage', 500),
|
|
'location_id' => rmmSiteLocationId($client_id, $facts['site']),
|
|
];
|
|
}
|
|
|
|
/** The asset fields the sync keeps up, as people read them. */
|
|
function rmmAssetFieldLabels(): array
|
|
{
|
|
return ['name' => 'name', 'type' => 'type', 'make' => 'make', 'model' => 'model', 'serial' => 'serial number', 'os' => 'OS', 'ip' => 'IP',
|
|
'cpu' => 'CPU', 'storage' => 'storage', 'location_id' => 'location'];
|
|
}
|
|
|
|
/** An asset's present values, for the fields the sync keeps up - null when the asset is gone. */
|
|
function rmmAssetCurrent(int $asset_id): ?array
|
|
{
|
|
global $mysqli;
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_type, asset_make, asset_model, asset_serial, asset_os, asset_location_id,
|
|
asset_status, asset_client_id, asset_archived_at, interface_id, interface_ip FROM assets
|
|
LEFT JOIN asset_interfaces ON interface_asset_id = asset_id AND interface_primary = 1
|
|
WHERE asset_id = $asset_id LIMIT 1"));
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
$specs = function_exists('assetSpecsReady') && assetSpecsReady() ? assetSpecs($asset_id) : [];
|
|
return [
|
|
'name' => (string) $row['asset_name'],
|
|
'type' => (string) $row['asset_type'],
|
|
'make' => (string) $row['asset_make'],
|
|
'model' => (string) $row['asset_model'],
|
|
'serial' => (string) $row['asset_serial'],
|
|
'os' => (string) $row['asset_os'],
|
|
'ip' => (string) $row['interface_ip'],
|
|
'cpu' => (string) ($specs['cpu'] ?? ''),
|
|
'storage' => (string) ($specs['storage'] ?? ''),
|
|
'location_id' => intval($row['asset_location_id']),
|
|
'_interface_id' => intval($row['interface_id']),
|
|
'_status' => (string) $row['asset_status'],
|
|
'_client_id' => intval($row['asset_client_id']),
|
|
];
|
|
}
|
|
|
|
/** Write fields to an asset - field => value, for the fields rmmAssetFieldLabels() names. */
|
|
function rmmAssetWrite(int $asset_id, array $set, array $current): void
|
|
{
|
|
global $mysqli;
|
|
$columns = ['name' => 'asset_name', 'type' => 'asset_type', 'make' => 'asset_make', 'model' => 'asset_model', 'serial' => 'asset_serial', 'os' => 'asset_os'];
|
|
$sets = [];
|
|
foreach ($columns as $field => $column) {
|
|
if (array_key_exists($field, $set)) {
|
|
$sets[] = "$column = '" . escapeSql($set[$field]) . "'";
|
|
}
|
|
}
|
|
if (array_key_exists('location_id', $set)) {
|
|
$sets[] = 'asset_location_id = ' . intval($set['location_id']);
|
|
}
|
|
if ($sets) {
|
|
mysqli_query($mysqli, "UPDATE assets SET " . implode(', ', $sets) . " WHERE asset_id = $asset_id");
|
|
}
|
|
|
|
if (array_key_exists('ip', $set)) {
|
|
$ip = escapeSql($set['ip']);
|
|
if ($current['_interface_id']) {
|
|
mysqli_query($mysqli, "UPDATE asset_interfaces SET interface_ip = '$ip' WHERE interface_id = " . intval($current['_interface_id']));
|
|
} else {
|
|
mysqli_query($mysqli, "INSERT INTO asset_interfaces SET interface_name = '01', interface_ip = '$ip', interface_primary = 1, interface_asset_id = $asset_id");
|
|
}
|
|
}
|
|
|
|
// CPU and storage are details of the asset's type (functions/asset_type.php) - only for a type that has them
|
|
$type = $set['type'] ?? $current['type'];
|
|
$allowed = function_exists('assetTypeSpecs') ? array_keys(assetTypeSpecs($type)) : [];
|
|
foreach (['cpu', 'storage'] as $key) {
|
|
if (array_key_exists($key, $set) && in_array($key, $allowed, true) && function_exists('assetSpecsReady') && assetSpecsReady()) {
|
|
$value = escapeSql($set[$key]);
|
|
mysqli_query($mysqli, "INSERT INTO asset_specs SET asset_spec_asset_id = $asset_id, asset_spec_key = '$key', asset_spec_value = '$value'
|
|
ON DUPLICATE KEY UPDATE asset_spec_value = VALUES(asset_spec_value)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bring an asset up to what its RMM says - the follow rule: a field is written when it is
|
|
* empty, or still holds what the sync last wrote; one changed in ITFlow is left. A blank from
|
|
* the RMM writes nothing. [$changed labels, $synced] - $synced is what to keep as last written.
|
|
*/
|
|
function rmmAssetFollow(int $asset_id, array $values, array $synced_before): array
|
|
{
|
|
$current = rmmAssetCurrent($asset_id);
|
|
if (!$current) {
|
|
return [[], $synced_before];
|
|
}
|
|
$labels = rmmAssetFieldLabels();
|
|
$set = [];
|
|
$synced = [];
|
|
foreach ($values as $field => $new) {
|
|
if (!isset($labels[$field])) {
|
|
continue;
|
|
}
|
|
// CPU and storage are details only some types have - none for a VM's CPU, or a type that has neither
|
|
if (in_array($field, ['cpu', 'storage'], true) && !in_array($field, array_keys(function_exists('assetTypeSpecs') ? assetTypeSpecs($set['type'] ?? $current['type']) : []), true)) {
|
|
continue;
|
|
}
|
|
$new = $field === 'location_id' ? intval($new) : (string) $new;
|
|
$had = $field === 'location_id' ? intval($current[$field]) : (string) $current[$field];
|
|
$was_ours = array_key_exists($field, $synced_before) && (string) $synced_before[$field] === (string) $had;
|
|
if ($new === '' || $new === 0) {
|
|
// The RMM doesn't say - nothing to write; what was ours stays ours
|
|
if ($was_ours) {
|
|
$synced[$field] = $synced_before[$field];
|
|
}
|
|
continue;
|
|
}
|
|
if ((string) $had === (string) $new) {
|
|
$synced[$field] = $new;
|
|
continue;
|
|
}
|
|
if ($had === '' || $had === 0 || $was_ours) {
|
|
$set[$field] = $new;
|
|
$synced[$field] = $new;
|
|
continue;
|
|
}
|
|
// Changed in ITFlow - left as it is, and still not ours next time
|
|
if (array_key_exists($field, $synced_before)) {
|
|
$synced[$field] = $synced_before[$field];
|
|
}
|
|
}
|
|
if ($set) {
|
|
rmmAssetWrite($asset_id, $set, $current);
|
|
}
|
|
return [array_values(array_intersect_key($labels, $set)), $synced];
|
|
}
|
|
|
|
/** A line in an asset's history, as the provider that wrote it - "Tactical RMM changed its OS". */
|
|
function rmmAssetHistory(int $asset_id, string $who, string $what): void
|
|
{
|
|
global $mysqli;
|
|
$status = escapeSql((string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT asset_status FROM assets WHERE asset_id = $asset_id"))[0] ?? ''));
|
|
$what = escapeSql(mb_substr("$who $what", 0, 255));
|
|
mysqli_query($mysqli, "INSERT INTO asset_history SET asset_history_status = '$status', asset_history_description = '$what', asset_history_asset_id = $asset_id");
|
|
}
|
|
|
|
/** A new asset for a device - its id. */
|
|
function rmmAssetCreate(int $client_id, array $values): int
|
|
{
|
|
global $mysqli;
|
|
$name = escapeSql($values['name'] !== '' ? $values['name'] : 'Unnamed device');
|
|
$type = escapeSql($values['type']);
|
|
$location_id = intval($values['location_id']);
|
|
mysqli_query($mysqli, "INSERT INTO assets SET asset_name = '$name', asset_type = '$type', asset_make = '', asset_status = 'Deployed',
|
|
asset_location_id = $location_id, asset_client_id = $client_id");
|
|
return intval(mysqli_insert_id($mysqli));
|
|
}
|
|
|
|
/**
|
|
* An asset already in ITFlow that is this device - same serial number, or the only one with
|
|
* its name - and not another of this provider's devices already. 0 when there is none.
|
|
*/
|
|
function rmmAssetMatch(int $provider_id, int $client_id, array $values): int
|
|
{
|
|
global $mysqli;
|
|
$taken = "asset_id NOT IN (SELECT rmm_device_asset_id FROM rmm_devices WHERE rmm_device_provider_id = $provider_id AND rmm_device_asset_id > 0)";
|
|
if ($values['serial'] !== '') {
|
|
$serial = escapeSql($values['serial']);
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id AND asset_archived_at IS NULL
|
|
AND asset_serial = '$serial' AND $taken ORDER BY asset_id ASC LIMIT 1"));
|
|
if ($row) {
|
|
return intval($row[0]);
|
|
}
|
|
}
|
|
if ($values['name'] !== '') {
|
|
$name = escapeSql($values['name']);
|
|
$sql = mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id AND asset_archived_at IS NULL
|
|
AND asset_name = '$name' AND $taken LIMIT 2");
|
|
if (mysqli_num_rows($sql) === 1) {
|
|
return intval(mysqli_fetch_row($sql)[0]);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/** A device's rmm_devices row by its id in the RMM - raw, or null. */
|
|
function rmmDeviceRow(int $provider_id, string $remote_id): ?array
|
|
{
|
|
global $mysqli;
|
|
$remote = escapeSql($remote_id);
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM rmm_devices WHERE rmm_device_provider_id = $provider_id AND rmm_device_remote_id = '$remote' LIMIT 1"));
|
|
return $row ?: null;
|
|
}
|
|
|
|
/**
|
|
* One device from an RMM, brought into ITFlow - its asset made, matched or kept up to date,
|
|
* and what it is doing saved. What happened goes into $counts. Its id in the RMM, or '' when
|
|
* it hasn't got one ITFlow can keep.
|
|
*/
|
|
function rmmDeviceSave(array $provider, array $rmm_client, int $client_id, array $device, array &$counts): string
|
|
{
|
|
global $mysqli, $session_name;
|
|
|
|
$provider_id = intval($provider['rmm_provider_id']);
|
|
$who = rmmProviderName($provider);
|
|
$remote_id = (string) ($device['remote_id'] ?? '');
|
|
if (!preg_match('/^[A-Za-z0-9_.:-]{1,100}$/', $remote_id)) {
|
|
$counts['skipped']++;
|
|
return '';
|
|
}
|
|
$facts = rmmDeviceFacts($device);
|
|
$values = rmmDeviceAssetValues($device, $facts, $client_id);
|
|
$row = rmmDeviceRow($provider_id, $remote_id);
|
|
$synced_before = [];
|
|
$asset_id = 0;
|
|
$is_new_row = $row === null;
|
|
|
|
if ($row) {
|
|
$synced_before = json_decode((string) $row['rmm_device_synced'], true) ?: [];
|
|
$asset_id = intval($row['rmm_device_asset_id']);
|
|
if ($asset_id && !rmmAssetCurrent($asset_id)) {
|
|
// Deleted in ITFlow - not made again until Bring back
|
|
$asset_id = 0;
|
|
}
|
|
}
|
|
|
|
$made = false;
|
|
$matched = false;
|
|
if ($is_new_row) {
|
|
$asset_id = rmmAssetMatch($provider_id, $client_id, $values);
|
|
if ($asset_id) {
|
|
$matched = true;
|
|
$counts['linked']++;
|
|
} else {
|
|
$asset_id = rmmAssetCreate($client_id, $values);
|
|
$made = true;
|
|
$counts['created']++;
|
|
}
|
|
}
|
|
|
|
$changed = [];
|
|
if ($asset_id) {
|
|
[$changed, $synced] = rmmAssetFollow($asset_id, $values, $made ? array_fill_keys(array_keys(rmmAssetFieldLabels()), '') : $synced_before);
|
|
if ($made) {
|
|
rmmAssetHistory($asset_id, $who, 'brought it in from ' . ($facts['site'] !== '' ? 'site ' . $facts['site'] . ' of ' : '') . $rmm_client['rmm_client_name']);
|
|
logAudit('Asset', 'Create', "$session_name brought in asset " . escapeSql($values['name']) . ' from ' . escapeSql($rmm_client['rmm_client_name']), $client_id, $asset_id);
|
|
} elseif ($matched) {
|
|
rmmAssetHistory($asset_id, $who, 'linked it to its device there, ' . $facts['hostname'] . ($changed ? ', and filled in its ' . implode(', ', $changed) : ''));
|
|
} elseif ($changed) {
|
|
$counts['updated']++;
|
|
rmmAssetHistory($asset_id, $who, 'changed its ' . implode(', ', $changed));
|
|
}
|
|
} else {
|
|
$synced = $synced_before;
|
|
}
|
|
|
|
if ($row && $row['rmm_device_removed_at'] !== null) {
|
|
$counts['returned']++;
|
|
if ($asset_id) {
|
|
rmmAssetHistory($asset_id, $who, 'has it again');
|
|
}
|
|
}
|
|
|
|
$sql_facts = [];
|
|
foreach ($facts as $field => $value) {
|
|
$sql_facts[] = "rmm_device_$field = " . ($value === null ? 'NULL' : (is_int($value) ? $value : "'" . escapeSql($value) . "'"));
|
|
}
|
|
$sql_facts = implode(', ', $sql_facts);
|
|
$synced_sql = escapeSql(json_encode($synced, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE));
|
|
$rmm_client_row_id = intval($rmm_client['rmm_client_id']);
|
|
|
|
if ($is_new_row) {
|
|
$remote = escapeSql($remote_id);
|
|
mysqli_query($mysqli, "INSERT INTO rmm_devices SET rmm_device_provider_id = $provider_id, rmm_device_remote_id = '$remote', rmm_device_asset_id = $asset_id,
|
|
rmm_device_client_id = $client_id, rmm_device_rmm_client_id = $rmm_client_row_id, $sql_facts, rmm_device_synced = '$synced_sql', rmm_device_synced_at = NOW()");
|
|
} else {
|
|
mysqli_query($mysqli, "UPDATE rmm_devices SET rmm_device_asset_id = $asset_id, rmm_device_client_id = $client_id, rmm_device_rmm_client_id = $rmm_client_row_id,
|
|
$sql_facts, rmm_device_synced = '$synced_sql', rmm_device_synced_at = NOW(), rmm_device_removed_at = NULL WHERE rmm_device_id = " . intval($row['rmm_device_id']));
|
|
}
|
|
$counts['devices']++;
|
|
return $remote_id;
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ Syncing
|
|
*/
|
|
|
|
/** A provider's rmm_clients rows that are linked to a client ITFlow still has, and that the RMM still has - oldest brought in first. */
|
|
function rmmLinkedClients(int $provider_id, int $only = 0): array
|
|
{
|
|
global $mysqli;
|
|
$only_sql = $only ? "AND rmm_client_id = $only" : '';
|
|
$rows = [];
|
|
$sql = mysqli_query($mysqli, "SELECT rmm_clients.*, client_name, client_archived_at FROM rmm_clients
|
|
JOIN clients ON client_id = rmm_client_client_id
|
|
WHERE rmm_client_provider_id = $provider_id AND rmm_client_client_id > 0 AND rmm_client_removed_at IS NULL $only_sql
|
|
ORDER BY rmm_client_synced_at IS NOT NULL, rmm_client_synced_at ASC, rmm_client_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Sync one provider - its clients, then the devices of each linked one. One at a time (a
|
|
* second sync of it started while one runs says so and stops). $only is an rmm_clients id,
|
|
* for bringing in one client's devices straight after it is linked. Stops after $max_seconds
|
|
* and picks up where it left off next time - the clients brought in longest ago go first.
|
|
*
|
|
* Returns ['ok', 'note', 'counts'] - note is what to show a person.
|
|
*/
|
|
function rmmSync(array $provider, int $only = 0, int $max_seconds = 120): array
|
|
{
|
|
global $mysqli, $session_name, $session_user_id, $session_ip, $session_user_agent;
|
|
|
|
$counts = ['clients' => 0, 'devices' => 0, 'created' => 0, 'linked' => 0, 'updated' => 0, 'removed' => 0, 'returned' => 0, 'skipped' => 0];
|
|
$name = rmmProviderName($provider);
|
|
if (!rmmProviderUsable($provider)) {
|
|
return ['ok' => false, 'note' => "$name isn't set up, or is turned off.", 'counts' => $counts];
|
|
}
|
|
$provider_id = intval($provider['rmm_provider_id']);
|
|
$short = rmmKind($provider)['short'];
|
|
|
|
$lock = "itflow_rmm_sync_$provider_id";
|
|
if (intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT GET_LOCK('$lock', 0)"))[0] ?? 0) !== 1) {
|
|
return ['ok' => false, 'note' => "A sync of $name is running already - give it a minute.", 'counts' => $counts];
|
|
}
|
|
|
|
// The audit log and asset history say the provider did it - and, for a Sync now, who asked (log_user_id)
|
|
$signed_in_name = $session_name ?? null;
|
|
$session_name = escapeSql($name);
|
|
$session_user_id = intval($session_user_id ?? 0);
|
|
$session_ip = $session_ip ?? '';
|
|
$session_user_agent = $session_user_agent ?? 'ITFlow cron';
|
|
|
|
$clock = microtime(true);
|
|
$errors = [];
|
|
$complete = []; // rmm_clients id => the ids of its devices, for each whose list came back whole - the rest of its devices are gone
|
|
$stopped = false;
|
|
|
|
$in_list = function (array $ids) {
|
|
return $ids ? "'" . implode("', '", array_map('escapeSql', $ids)) . "'" : "''";
|
|
};
|
|
|
|
try {
|
|
$answer = rmmRemoteClients($provider);
|
|
if (!$answer['ok']) {
|
|
throw new RuntimeException($answer['error']);
|
|
}
|
|
|
|
// Its clients - new ones added, names and counts kept up, the ones it no longer has marked
|
|
foreach ($answer['clients'] as $client) {
|
|
$remote = escapeSql($client['id']);
|
|
$client_name = escapeSql($client['name']);
|
|
mysqli_query($mysqli, "INSERT INTO rmm_clients SET rmm_client_provider_id = $provider_id, rmm_client_remote_id = '$remote', rmm_client_name = '$client_name',
|
|
rmm_client_sites = " . intval($client['sites']) . ", rmm_client_devices = " . intval($client['devices']) . ", rmm_client_seen_at = NOW()
|
|
ON DUPLICATE KEY UPDATE rmm_client_name = VALUES(rmm_client_name), rmm_client_sites = VALUES(rmm_client_sites),
|
|
rmm_client_devices = VALUES(rmm_client_devices), rmm_client_seen_at = NOW(), rmm_client_removed_at = NULL");
|
|
}
|
|
$counts['clients'] = count($answer['clients']);
|
|
if (!$only) {
|
|
$listed = $in_list(array_column($answer['clients'], 'id'));
|
|
$gone = mysqli_query($mysqli, "SELECT rmm_client_id FROM rmm_clients WHERE rmm_client_provider_id = $provider_id AND rmm_client_removed_at IS NULL
|
|
AND rmm_client_remote_id NOT IN ($listed)");
|
|
while ($gone_row = mysqli_fetch_row($gone)) {
|
|
$complete[intval($gone_row[0])] = [];
|
|
mysqli_query($mysqli, "UPDATE rmm_clients SET rmm_client_removed_at = NOW() WHERE rmm_client_id = " . intval($gone_row[0]));
|
|
}
|
|
}
|
|
|
|
foreach (rmmLinkedClients($provider_id, $only) as $rmm_client) {
|
|
if (microtime(true) - $clock > $max_seconds) {
|
|
$stopped = true;
|
|
break;
|
|
}
|
|
if ($rmm_client['client_archived_at'] !== null) {
|
|
continue;
|
|
}
|
|
$devices = rmmRemoteDevices($provider, (string) $rmm_client['rmm_client_remote_id']);
|
|
if (!$devices['ok']) {
|
|
$errors[] = $rmm_client['rmm_client_name'] . ': ' . $devices['error'];
|
|
continue;
|
|
}
|
|
$seen = [];
|
|
foreach ($devices['devices'] as $device) {
|
|
if (is_array($device)) {
|
|
$seen[] = rmmDeviceSave($provider, $rmm_client, intval($rmm_client['rmm_client_client_id']), $device, $counts);
|
|
}
|
|
}
|
|
$complete[intval($rmm_client['rmm_client_id'])] = array_values(array_filter($seen, 'strlen'));
|
|
mysqli_query($mysqli, "UPDATE rmm_clients SET rmm_client_synced_at = NOW() WHERE rmm_client_id = " . intval($rmm_client['rmm_client_id']));
|
|
}
|
|
|
|
// Devices of the clients brought in whole that the RMM didn't list - it no longer has them
|
|
foreach ($complete as $rmm_client_id => $seen) {
|
|
$sql = mysqli_query($mysqli, "SELECT rmm_device_id, rmm_device_asset_id FROM rmm_devices WHERE rmm_device_provider_id = $provider_id
|
|
AND rmm_device_rmm_client_id = $rmm_client_id AND rmm_device_removed_at IS NULL AND rmm_device_remote_id NOT IN (" . $in_list($seen) . ")");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
mysqli_query($mysqli, "UPDATE rmm_devices SET rmm_device_removed_at = NOW() WHERE rmm_device_id = " . intval($row['rmm_device_id']));
|
|
if (intval($row['rmm_device_asset_id']) && rmmAssetCurrent(intval($row['rmm_device_asset_id']))) {
|
|
rmmAssetHistory(intval($row['rmm_device_asset_id']), $name, 'no longer has it');
|
|
}
|
|
$counts['removed']++;
|
|
}
|
|
}
|
|
|
|
$note = number_format($counts['devices']) . ' device' . ($counts['devices'] === 1 ? '' : 's') . ' brought in';
|
|
$done = [];
|
|
foreach (['created' => 'new', 'linked' => 'matched to assets already here', 'updated' => 'updated', 'removed' => "no longer in $short", 'returned' => "back in $short"] as $key => $words) {
|
|
if ($counts[$key]) {
|
|
$done[] = number_format($counts[$key]) . " $words";
|
|
}
|
|
}
|
|
$note .= $done ? ' - ' . implode(', ', $done) : '';
|
|
if ($stopped) {
|
|
$note .= '. Stopped for time - the rest come in next sync';
|
|
}
|
|
if ($errors) {
|
|
$note .= '. Not brought in: ' . implode('; ', $errors);
|
|
logApp('RMM', 'warning', "$name: sync - " . implode('; ', $errors));
|
|
}
|
|
$ok = !$errors;
|
|
} catch (Throwable $e) {
|
|
$ok = false;
|
|
$note = $e->getMessage();
|
|
logApp('RMM', 'warning', "$name: sync failed - $note");
|
|
}
|
|
|
|
$note_sql = escapeSql(mb_substr($note, 0, 500));
|
|
mysqli_query($mysqli, "UPDATE rmm_providers SET rmm_provider_sync_at = NOW(), rmm_provider_sync_ok = " . ($ok ? 1 : 0) . ", rmm_provider_sync_note = '$note_sql' WHERE rmm_provider_id = $provider_id");
|
|
|
|
if ($counts['updated'] || $counts['removed'] || $counts['returned']) {
|
|
logAudit('RMM Provider', 'Sync', escapeSql("$name synced devices: $note"), 0, $provider_id);
|
|
}
|
|
|
|
mysqli_query($mysqli, "SELECT RELEASE_LOCK('$lock')");
|
|
$session_name = $signed_in_name ?? $session_name;
|
|
|
|
return ['ok' => $ok, 'note' => $note, 'counts' => $counts];
|
|
}
|
|
|
|
/**
|
|
* Sync every provider that is on - for the RMM Sync job. They share $max_seconds, each picking
|
|
* up next run where it stopped. [provider name => rmmSync()'s answer].
|
|
*/
|
|
function rmmSyncAll(int $max_seconds = 120): array
|
|
{
|
|
$results = [];
|
|
$clock = microtime(true);
|
|
foreach (rmmProviders(true) as $provider) {
|
|
if (!rmmProviderUsable($provider)) {
|
|
continue;
|
|
}
|
|
$left = $max_seconds - (int) (microtime(true) - $clock);
|
|
if ($left < 5) {
|
|
$results[rmmProviderName($provider)] = ['ok' => true, 'note' => 'Left for the next run - out of time', 'counts' => []];
|
|
continue;
|
|
}
|
|
$results[rmmProviderName($provider)] = rmmSync($provider, 0, $left);
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ Alerts
|
|
*
|
|
* A provider with alerts turned on (rmm_provider_alert_level) is looked at every minute
|
|
* (cron/rmm_alerts.php). An alert at or above its level, on a device ITFlow has, opens a ticket
|
|
* on the device's client and asset - or, when the same thing on the same device already has a
|
|
* ticket that isn't closed, goes on that one, reopening it if it was resolved. When the RMM no
|
|
* longer lists an alert as open it is asked about that one: once it is cleared, or gone, its
|
|
* ticket gets a note and is resolved - unless someone has written on it, it is in a status
|
|
* someone moved it to, or another of its alerts is still open.
|
|
*
|
|
* The first look takes the alerts already open as known, without tickets - turning alerts on
|
|
* doesn't open one for everything that has been alerting for weeks - and so does an alert
|
|
* raised before its device came into ITFlow.
|
|
*
|
|
* An alert, as a kind's alerts and alert functions give it:
|
|
* remote_id - its id in the RMM (letters, digits, _ . : -)
|
|
* device_remote_id - its device's id there, as rmm_devices.rmm_device_remote_id has it
|
|
* type - availability, check, task...
|
|
* about - what on the device it is about, so a repeat finds its ticket - check:12
|
|
* severity - error, warning or info
|
|
* message - what it says, as rmmText() keeps it
|
|
* raised_at - from rmmTime()
|
|
* snoozed - true while it is snoozed there: no ticket until it isn't
|
|
* cleared_at - from rmmTime() once it is cleared, else null
|
|
*/
|
|
|
|
/** Which alerts open tickets - level => what it is called. */
|
|
function rmmAlertLevels(): array
|
|
{
|
|
return ['' => 'Off', 'error' => 'Errors', 'warning' => 'Errors and warnings', 'info' => 'Every alert'];
|
|
}
|
|
|
|
/** Whether an alert of $severity is one $level opens a ticket for. */
|
|
function rmmAlertSeverityPasses(string $severity, string $level): bool
|
|
{
|
|
$rank = ['info' => 1, 'warning' => 2, 'error' => 3];
|
|
return isset($rank[$severity], $rank[$level]) && $rank[$severity] >= $rank[$level];
|
|
}
|
|
|
|
/** The priority an alert's ticket gets. */
|
|
function rmmAlertPriority(string $severity): string
|
|
{
|
|
return ['error' => 'High', 'warning' => 'Medium', 'info' => 'Low'][$severity] ?? 'Medium';
|
|
}
|
|
|
|
/** Whether the alerts table is there - it arrives with the 3.6.8 database update. */
|
|
function rmmAlertsReady(): bool
|
|
{
|
|
global $mysqli;
|
|
static $ready = null;
|
|
if ($ready === null) {
|
|
try {
|
|
mysqli_query($mysqli, "SELECT 1 FROM rmm_alerts LIMIT 1");
|
|
$ready = true;
|
|
} catch (Throwable $e) {
|
|
$ready = false;
|
|
}
|
|
}
|
|
return $ready;
|
|
}
|
|
|
|
/** Whether a provider's alerts open tickets - on, its kind able to, a level set. */
|
|
function rmmAlertsOn(?array $provider): bool
|
|
{
|
|
$kind = $provider ? rmmKind($provider) : null;
|
|
$level = (string) ($provider['rmm_provider_alert_level'] ?? '');
|
|
return $kind && isset($kind['alerts'], $kind['alert']) && rmmProviderUsable($provider) && $level !== '' && isset(rmmAlertLevels()[$level]) && rmmAlertsReady();
|
|
}
|
|
|
|
/** One alert's row by its id in the RMM - raw, or null. */
|
|
function rmmAlertRow(int $provider_id, string $remote_id): ?array
|
|
{
|
|
global $mysqli;
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM rmm_alerts WHERE rmm_alert_provider_id = $provider_id AND rmm_alert_remote_id = '" . escapeSql($remote_id) . "' LIMIT 1"));
|
|
return $row ?: null;
|
|
}
|
|
|
|
/** Keep an alert ITFlow has seen, on $ticket_id (0: none). */
|
|
function rmmAlertInsert(int $provider_id, array $device, array $alert, int $ticket_id): void
|
|
{
|
|
global $mysqli;
|
|
$raised = $alert['raised_at'] ? "'" . escapeSql($alert['raised_at']) . "'" : 'NULL';
|
|
mysqli_query($mysqli, "INSERT IGNORE INTO rmm_alerts SET rmm_alert_provider_id = $provider_id, rmm_alert_remote_id = '" . escapeSql($alert['remote_id']) . "',
|
|
rmm_alert_device_id = " . intval($device['rmm_device_id']) . ", rmm_alert_asset_id = " . intval($device['rmm_device_asset_id']) . ",
|
|
rmm_alert_client_id = " . intval($device['rmm_device_client_id']) . ", rmm_alert_about = '" . escapeSql(mb_substr((string) $alert['about'], 0, 150)) . "',
|
|
rmm_alert_type = '" . escapeSql(mb_substr((string) $alert['type'], 0, 20)) . "', rmm_alert_severity = '" . escapeSql(mb_substr((string) $alert['severity'], 0, 20)) . "',
|
|
rmm_alert_message = '" . escapeSql((string) $alert['message']) . "', rmm_alert_raised_at = $raised, rmm_alert_ticket_id = $ticket_id");
|
|
}
|
|
|
|
/** An internal note on a ticket, written by ITFlow - $text is plain. */
|
|
function rmmTicketNote(int $ticket_id, string $text): void
|
|
{
|
|
global $mysqli;
|
|
// HTML, escaped as itself - escapeSql() would take the tags out
|
|
$html = mysqli_real_escape_string($mysqli, '<p>' . nl2br(escapeHtml($text)) . '</p>');
|
|
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$html', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = 0, ticket_reply_ticket_id = $ticket_id");
|
|
mysqli_query($mysqli, "UPDATE tickets SET ticket_updated_at = NOW() WHERE ticket_id = $ticket_id");
|
|
}
|
|
|
|
/** Whether someone has written on a ticket - a reply or note that isn't ITFlow's own. */
|
|
function rmmTicketWorked(int $ticket_id): bool
|
|
{
|
|
global $mysqli;
|
|
return (bool) mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_by > 0 AND ticket_reply_archived_at IS NULL LIMIT 1"));
|
|
}
|
|
|
|
/** What a ticket for an alert says - HTML. */
|
|
function rmmAlertDetailsHtml(array $provider, array $device, array $alert): string
|
|
{
|
|
$name = rmmProviderName($provider);
|
|
$severities = ['error' => 'Error', 'warning' => 'Warning', 'info' => 'Informational'];
|
|
$types = ['availability' => 'Not checking in', 'check' => 'Check', 'task' => 'Task', 'custom' => 'Custom'];
|
|
$facts = [
|
|
'Severity' => $severities[$alert['severity']] ?? ucfirst((string) $alert['severity']),
|
|
'Kind' => $types[$alert['type']] ?? ucfirst((string) $alert['type']),
|
|
'Raised' => $alert['raised_at'] ? date('M j, Y g:i A', strtotime($alert['raised_at'])) : '',
|
|
'Device' => trim($device['rmm_device_hostname'] . ($device['rmm_device_site'] ? ' (' . $device['rmm_device_site'] . ')' : '')),
|
|
];
|
|
$html = '<p><strong>' . escapeHtml($alert['message'] !== '' ? $alert['message'] : 'An alert') . '</strong></p><p>';
|
|
foreach (array_filter($facts, 'strlen') as $label => $value) {
|
|
$html .= escapeHtml($label) . ': ' . escapeHtml($value) . '<br>';
|
|
}
|
|
$html .= 'From: ' . escapeHtml($name) . '</p>';
|
|
$urls = rmmDeviceUrls(array_merge($provider, $device));
|
|
if (isset($urls['open'])) {
|
|
$html .= '<p><a href="' . escapeHtml($urls['open']) . '">' . escapeHtml(rmmKind($provider)['links']['open'] ?? "Open in $name") . '</a></p>';
|
|
}
|
|
return $html;
|
|
}
|
|
|
|
/** A ticket for an alert on a device - its id. */
|
|
function rmmAlertTicketOpen(array $provider, array $device, array $alert): int
|
|
{
|
|
global $mysqli, $session_name;
|
|
|
|
$settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_ticket_prefix, config_ticket_default_billable FROM settings WHERE company_id = 1"));
|
|
mysqli_query($mysqli, "UPDATE settings SET config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number), config_ticket_next_number = config_ticket_next_number + 1 WHERE company_id = 1");
|
|
$ticket_number = intval(mysqli_insert_id($mysqli));
|
|
|
|
$client_id = intval($device['rmm_device_client_id']);
|
|
// Its asset, when it is still the client's
|
|
$asset_id = intval($device['rmm_device_asset_id']);
|
|
if ($asset_id && intval(getFieldById('assets', $asset_id, 'asset_client_id')) !== $client_id) {
|
|
$asset_id = 0;
|
|
}
|
|
$subject = mb_strimwidth($alert['message'] !== '' ? $alert['message'] : 'Alert on ' . $device['rmm_device_hostname'], 0, 200, '...');
|
|
$prefix = escapeSql((string) $settings['config_ticket_prefix']);
|
|
$billable = intval($settings['config_ticket_default_billable']) ? 1 : 0;
|
|
$category = function_exists('ticketCategoryId') ? ticketCategoryId($provider['rmm_provider_alert_category_id'] ?? 0) : 0;
|
|
$priority = escapeSql(rmmAlertPriority((string) $alert['severity']));
|
|
$details = mysqli_real_escape_string($mysqli, rmmAlertDetailsHtml($provider, $device, $alert));
|
|
|
|
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$prefix', ticket_number = $ticket_number, ticket_source = 'RMM', ticket_category = $category,
|
|
ticket_subject = '" . escapeSql($subject) . "', ticket_details = '$details', ticket_priority = '$priority', ticket_status = " . TICKET_STATUS_NEW . ",
|
|
ticket_billable = $billable, ticket_created_by = 0, ticket_contact_id = 0, ticket_asset_id = $asset_id, ticket_url_key = '" . randomString(32) . "', ticket_client_id = $client_id");
|
|
$ticket_id = intval(mysqli_insert_id($mysqli));
|
|
applyTicketSla($ticket_id);
|
|
|
|
$reference = $settings['config_ticket_prefix'] . $ticket_number;
|
|
$host = (string) $device['rmm_device_hostname'];
|
|
logAudit('Ticket', 'Create', "$session_name opened ticket " . escapeSql("$reference for an alert on $host - $subject"), $client_id, $ticket_id);
|
|
logTicketHistory($ticket_id, "Opened by $session_name for an alert on " . escapeSql($host));
|
|
ticketMailNewTicketAddress($ticket_id, 'rmm');
|
|
triggerCustomAction('ticket_create', $ticket_id);
|
|
return $ticket_id;
|
|
}
|
|
|
|
/**
|
|
* A new alert on a device - on the ticket the same thing on it already has, if that isn't
|
|
* closed (reopened if it was resolved), or a new one. Which it was goes into $counts.
|
|
*/
|
|
function rmmAlertTicket(array $provider, array $device, array $alert, array &$counts): void
|
|
{
|
|
global $mysqli;
|
|
$provider_id = intval($provider['rmm_provider_id']);
|
|
$name = rmmProviderName($provider);
|
|
$existing = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT rmm_alert_ticket_id FROM rmm_alerts
|
|
JOIN tickets ON ticket_id = rmm_alert_ticket_id
|
|
WHERE rmm_alert_provider_id = $provider_id AND rmm_alert_device_id = " . intval($device['rmm_device_id']) . "
|
|
AND rmm_alert_about = '" . escapeSql(mb_substr((string) $alert['about'], 0, 150)) . "' AND rmm_alert_ticket_id > 0
|
|
AND ticket_status <> " . TICKET_STATUS_CLOSED . " AND ticket_archived_at IS NULL
|
|
ORDER BY rmm_alert_id DESC LIMIT 1"));
|
|
|
|
if ($existing) {
|
|
$ticket_id = intval($existing['rmm_alert_ticket_id']);
|
|
rmmAlertInsert($provider_id, $device, $alert, $ticket_id);
|
|
rmmTicketNote($ticket_id, "$name: alerting again - " . ($alert['message'] !== '' ? $alert['message'] : 'the same alert'));
|
|
$ticket = ticketRow($ticket_id);
|
|
if ($ticket && intval($ticket['ticket_status']) === TICKET_STATUS_RESOLVED) {
|
|
ticketReopen($ticket, TICKET_STATUS_OPEN, ' - the alert came back');
|
|
} else {
|
|
logTicketHistory($ticket_id, escapeSql("$name: alerting again"));
|
|
}
|
|
$counts['again']++;
|
|
return;
|
|
}
|
|
|
|
$ticket_id = rmmAlertTicketOpen($provider, $device, $alert);
|
|
rmmAlertInsert($provider_id, $device, $alert, $ticket_id);
|
|
$counts['opened']++;
|
|
}
|
|
|
|
/**
|
|
* An alert of a ticket that has cleared - $alert as the RMM has it now, null when it no longer
|
|
* has it. The ticket gets a note, and is resolved when nothing else keeps it open.
|
|
*/
|
|
function rmmAlertCleared(array $provider, array $row, ?array $alert, array &$counts): void
|
|
{
|
|
global $mysqli;
|
|
$name = rmmProviderName($provider);
|
|
$cleared_at = $alert && $alert['cleared_at'] ? "'" . escapeSql($alert['cleared_at']) . "'" : 'NOW()';
|
|
mysqli_query($mysqli, "UPDATE rmm_alerts SET rmm_alert_cleared_at = $cleared_at WHERE rmm_alert_id = " . intval($row['rmm_alert_id']));
|
|
$counts['cleared']++;
|
|
|
|
$ticket_id = intval($row['rmm_alert_ticket_id']);
|
|
$ticket = $ticket_id ? ticketRow($ticket_id) : null;
|
|
if (!$ticket || intval($ticket['ticket_status']) === TICKET_STATUS_CLOSED || $ticket['ticket_archived_at'] !== null) {
|
|
return;
|
|
}
|
|
$what = trim((string) $row['rmm_alert_message']);
|
|
rmmTicketNote($ticket_id, "$name: " . ($alert ? 'the alert cleared' : 'it no longer has the alert') . ($what !== '' ? " - $what" : ''));
|
|
|
|
$still = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM rmm_alerts WHERE rmm_alert_ticket_id = $ticket_id AND rmm_alert_cleared_at IS NULL"))[0]);
|
|
$status = intval($ticket['ticket_status']);
|
|
$left = $still ? "$still more still open" : (!in_array($status, [TICKET_STATUS_NEW, TICKET_STATUS_OPEN], true) ? 'left ' . getTicketStatusName($status)
|
|
: (rmmTicketWorked($ticket_id) ? 'left open, as someone has written on it' : ''));
|
|
if ($left !== '') {
|
|
logTicketHistory($ticket_id, escapeSql("$name: the alert cleared - $left"));
|
|
return;
|
|
}
|
|
ticketResolve($ticket, ' - the alert cleared');
|
|
$counts['resolved']++;
|
|
}
|
|
|
|
/**
|
|
* Look at one provider's alerts - new ones open tickets, cleared ones resolve them. One at a
|
|
* time per provider. Returns ['ok', 'note', 'counts'] - note is what to show a person.
|
|
*/
|
|
function rmmAlertsPoll(array $provider, int $max_seconds = 50): array
|
|
{
|
|
global $mysqli, $session_name, $session_user_id, $session_ip, $session_user_agent;
|
|
|
|
$counts = ['opened' => 0, 'again' => 0, 'known' => 0, 'cleared' => 0, 'resolved' => 0];
|
|
$name = rmmProviderName($provider);
|
|
if (!rmmAlertsOn($provider)) {
|
|
return ['ok' => false, 'note' => "$name's alerts don't open tickets.", 'counts' => $counts];
|
|
}
|
|
$provider_id = intval($provider['rmm_provider_id']);
|
|
$kind = rmmKind($provider);
|
|
$level = (string) $provider['rmm_provider_alert_level'];
|
|
|
|
$lock = "itflow_rmm_alerts_$provider_id";
|
|
if (intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT GET_LOCK('$lock', 0)"))[0] ?? 0) !== 1) {
|
|
return ['ok' => true, 'note' => 'Still looking from the last run', 'counts' => $counts];
|
|
}
|
|
|
|
// Tickets, notes and the audit log say the provider did it
|
|
$signed_in_name = $session_name ?? null;
|
|
$session_name = escapeSql($name);
|
|
$session_user_id = intval($session_user_id ?? 0);
|
|
$session_ip = $session_ip ?? '';
|
|
$session_user_agent = $session_user_agent ?? 'ITFlow cron';
|
|
|
|
$clock = microtime(true);
|
|
$first = $provider['rmm_provider_alerts_since'] === null;
|
|
$errors = [];
|
|
|
|
try {
|
|
$answer = call_user_func($kind['alerts'], $provider);
|
|
if (!$answer['ok']) {
|
|
throw new RuntimeException($answer['error']);
|
|
}
|
|
|
|
$open = [];
|
|
foreach ($answer['alerts'] as $alert) {
|
|
if (!is_array($alert) || !preg_match('/^[A-Za-z0-9_.:-]{1,100}$/', (string) ($alert['remote_id'] ?? ''))) {
|
|
continue;
|
|
}
|
|
$open[] = (string) $alert['remote_id'];
|
|
if (!rmmAlertSeverityPasses((string) $alert['severity'], $level) || rmmAlertRow($provider_id, (string) $alert['remote_id'])) {
|
|
continue;
|
|
}
|
|
// On a device ITFlow has, of a client that is still linked and not archived
|
|
$device = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT rmm_devices.* FROM rmm_devices
|
|
JOIN rmm_clients ON rmm_clients.rmm_client_id = rmm_device_rmm_client_id AND rmm_client_client_id = rmm_device_client_id
|
|
JOIN clients ON client_id = rmm_device_client_id AND client_archived_at IS NULL
|
|
WHERE rmm_device_provider_id = $provider_id AND rmm_device_remote_id = '" . escapeSql((string) $alert['device_remote_id']) . "'
|
|
AND rmm_device_removed_at IS NULL AND rmm_device_client_id > 0 LIMIT 1"));
|
|
if (!$device) {
|
|
continue;
|
|
}
|
|
// Open at the first look, or raised before its device came into ITFlow (its client linked since) - known, with no ticket.
|
|
// Anything else it hasn't seen is new: the first look took in all that was open, so no clocks are compared but this one
|
|
$raised = $alert['raised_at'] ? strtotime($alert['raised_at']) : time();
|
|
if ($first || $raised < strtotime((string) $device['rmm_device_created_at'])) {
|
|
rmmAlertInsert($provider_id, $device, $alert, 0);
|
|
$counts['known']++;
|
|
continue;
|
|
}
|
|
if (!empty($alert['snoozed'])) {
|
|
continue;
|
|
}
|
|
rmmAlertTicket($provider, $device, $alert, $counts);
|
|
}
|
|
if ($first) {
|
|
mysqli_query($mysqli, "UPDATE rmm_providers SET rmm_provider_alerts_since = NOW() WHERE rmm_provider_id = $provider_id");
|
|
}
|
|
|
|
// The ones still open here that it no longer lists as open - asked about one by one, a few each run
|
|
$in_list = $open ? "'" . implode("', '", array_map('escapeSql', $open)) . "'" : "''";
|
|
$sql = mysqli_query($mysqli, "SELECT * FROM rmm_alerts WHERE rmm_alert_provider_id = $provider_id AND rmm_alert_cleared_at IS NULL
|
|
AND rmm_alert_remote_id NOT IN ($in_list) ORDER BY rmm_alert_id ASC LIMIT 25");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
if (microtime(true) - $clock > $max_seconds) {
|
|
break;
|
|
}
|
|
$one = call_user_func($kind['alert'], $provider, (string) $row['rmm_alert_remote_id']);
|
|
if (!$one['ok']) {
|
|
$errors[] = $one['error'];
|
|
break;
|
|
}
|
|
if ($one['alert'] === null || $one['alert']['cleared_at'] !== null) {
|
|
rmmAlertCleared($provider, $row, $one['alert'], $counts);
|
|
}
|
|
}
|
|
|
|
$done = [];
|
|
foreach (['opened' => 'ticket opened', 'again' => 'added to its ticket', 'cleared' => 'cleared', 'resolved' => 'ticket resolved'] as $key => $words) {
|
|
if ($counts[$key]) {
|
|
$done[] = $counts[$key] . ' ' . ($counts[$key] === 1 ? $words : str_replace(['ticket opened', 'ticket resolved'], ['tickets opened', 'tickets resolved'], $words));
|
|
}
|
|
}
|
|
$note = $first ? number_format($counts['known']) . ' open alert' . ($counts['known'] === 1 ? '' : 's') . ' taken as known - new ones open tickets from now'
|
|
: ($done ? implode(', ', $done) : 'Nothing new');
|
|
if ($errors) {
|
|
throw new RuntimeException(reset($errors));
|
|
}
|
|
$ok = true;
|
|
} catch (Throwable $e) {
|
|
$ok = false;
|
|
$note = $e->getMessage();
|
|
}
|
|
|
|
// What went wrong is logged once, when it starts going wrong - not every minute it stays so
|
|
$note_before = (string) ($provider['rmm_provider_alerts_note'] ?? '');
|
|
if (!$ok && $note !== $note_before) {
|
|
logApp('RMM', 'warning', "$name: alerts - $note");
|
|
}
|
|
$note_sql = $ok ? 'NULL' : "'" . escapeSql(mb_substr($note, 0, 500)) . "'";
|
|
mysqli_query($mysqli, "UPDATE rmm_providers SET rmm_provider_alerts_at = NOW(), rmm_provider_alerts_note = $note_sql WHERE rmm_provider_id = $provider_id");
|
|
|
|
mysqli_query($mysqli, "SELECT RELEASE_LOCK('$lock')");
|
|
$session_name = $signed_in_name ?? $session_name;
|
|
|
|
return ['ok' => $ok, 'note' => $note, 'counts' => $counts];
|
|
}
|
|
|
|
/** Look at the alerts of every provider whose alerts open tickets - for the RMM Alerts job. [provider name => rmmAlertsPoll()'s answer]. */
|
|
function rmmAlertsPollAll(int $max_seconds = 50): array
|
|
{
|
|
$results = [];
|
|
$clock = microtime(true);
|
|
foreach (rmmProviders(true) as $provider) {
|
|
if (!rmmAlertsOn($provider)) {
|
|
continue;
|
|
}
|
|
$left = $max_seconds - (int) (microtime(true) - $clock);
|
|
if ($left < 5) {
|
|
break;
|
|
}
|
|
$results[rmmProviderName($provider)] = rmmAlertsPoll($provider, $left);
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
/** The alerts still open on some devices that have tickets - rmm_device_id => rows with the ticket's reference and status. */
|
|
function rmmOpenAlertsForDevices(array $device_ids): array
|
|
{
|
|
global $mysqli;
|
|
$device_ids = array_values(array_filter(array_map('intval', $device_ids)));
|
|
if (!$device_ids || !rmmAlertsReady()) {
|
|
return [];
|
|
}
|
|
$rows = [];
|
|
$sql = mysqli_query($mysqli, "SELECT rmm_alert_device_id, rmm_alert_severity, rmm_alert_message, rmm_alert_raised_at, ticket_id, ticket_prefix, ticket_number, ticket_client_id
|
|
FROM rmm_alerts JOIN tickets ON ticket_id = rmm_alert_ticket_id
|
|
WHERE rmm_alert_device_id IN (" . implode(', ', $device_ids) . ") AND rmm_alert_cleared_at IS NULL AND rmm_alert_ticket_id > 0
|
|
ORDER BY rmm_alert_raised_at DESC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$rows[intval($row['rmm_alert_device_id'])][] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ Showing it
|
|
*/
|
|
|
|
/**
|
|
* Devices from an RMM for some assets - asset_id => their rmm_devices rows with their
|
|
* provider's columns, the ones still in their RMM first, then the one seen last first.
|
|
*/
|
|
function rmmDevicesForAssets(array $asset_ids): array
|
|
{
|
|
global $mysqli;
|
|
$asset_ids = array_values(array_filter(array_map('intval', $asset_ids)));
|
|
if (!$asset_ids || !rmmTablesReady()) {
|
|
return [];
|
|
}
|
|
$rows = [];
|
|
$sql = mysqli_query($mysqli, "SELECT rmm_devices.*, rmm_providers.*, rmm_client_name, rmm_client_client_id FROM rmm_devices
|
|
JOIN rmm_providers ON rmm_provider_id = rmm_device_provider_id
|
|
LEFT JOIN rmm_clients ON rmm_clients.rmm_client_id = rmm_device_rmm_client_id
|
|
WHERE rmm_device_asset_id IN (" . implode(', ', $asset_ids) . ")
|
|
ORDER BY rmm_device_removed_at IS NOT NULL, rmm_device_last_seen_at IS NULL, rmm_device_last_seen_at DESC, rmm_device_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$rows[intval($row['rmm_device_asset_id'])][] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/** The devices from an RMM one asset is - most likely to matter first. */
|
|
function rmmDevicesForAsset(int $asset_id): array
|
|
{
|
|
return rmmDevicesForAssets([$asset_id])[$asset_id] ?? [];
|
|
}
|
|
|
|
/**
|
|
* How a device is, in a word - ['key', 'label', 'tone', 'title']: tone is a Bootstrap colour.
|
|
* Gone from its RMM says so; a sync that hasn't run for an hour says what it last knew.
|
|
*/
|
|
function rmmDeviceState(array $row): array
|
|
{
|
|
$name = rmmProviderName($row);
|
|
$seen = $row['rmm_device_last_seen_at'] ? 'Last seen ' . timeAgo($row['rmm_device_last_seen_at']) : 'Never seen';
|
|
if ($row['rmm_device_removed_at'] !== null) {
|
|
return ['key' => 'removed', 'label' => "No longer in $name", 'tone' => 'secondary', 'title' => "$name stopped listing it " . timeAgo($row['rmm_device_removed_at']) . ". $seen."];
|
|
}
|
|
$as_of = $row['rmm_device_synced_at'] && strtotime($row['rmm_device_synced_at']) < time() - 3600 ? ' (as of ' . timeAgo($row['rmm_device_synced_at']) . ')' : '';
|
|
$states = [
|
|
'online' => ['Online', 'success'],
|
|
'offline' => ['Offline', 'secondary'],
|
|
'overdue' => ['Overdue', 'danger'],
|
|
];
|
|
[$label, $tone] = $states[$row['rmm_device_status']] ?? ['Unknown', 'secondary'];
|
|
if (intval($row['rmm_device_maintenance'])) {
|
|
$label .= ', maintenance';
|
|
}
|
|
return ['key' => $row['rmm_device_status'] ?: 'unknown', 'label' => $label, 'tone' => $tone, 'title' => "$name: $seen$as_of"];
|
|
}
|
|
|
|
/** A device's state as a small pill - for the asset page and its quick look. */
|
|
function rmmDeviceStatePill(array $row): string
|
|
{
|
|
$state = rmmDeviceState($row);
|
|
$icon = rmmKind($row)['icon'] ?? 'fas fa-laptop-medical';
|
|
return '<span class="badge rounded-pill text-bg-' . $state['tone'] . ' fw-normal" data-rmm-state="' . escapeHtml($state['key']) . '" title="' . escapeHtml($state['title']) . '">'
|
|
. '<i class="' . escapeHtml($icon) . ' fa-fw me-1"></i>' . escapeHtml($state['label']) . '</span>';
|
|
}
|
|
|
|
/** An asset's state in its RMM as a dot - for the assets list. Its first device's state; the title says each. */
|
|
function rmmDeviceStateDot(array $rows): string
|
|
{
|
|
if (!$rows) {
|
|
return '';
|
|
}
|
|
$state = rmmDeviceState($rows[0]);
|
|
$titles = [];
|
|
foreach ($rows as $row) {
|
|
$each = rmmDeviceState($row);
|
|
$titles[] = rmmProviderName($row) . ': ' . $each['label'] . ($each['key'] === 'removed' ? '' : ' - ' . preg_replace('/^[^:]*: /', '', $each['title']));
|
|
}
|
|
return '<span class="itflow-rmm-dot itflow-above-stretch bg-' . $state['tone'] . ($state['key'] === 'removed' ? ' is-removed' : '') . '" data-rmm-state="' . escapeHtml($state['key']) . '"'
|
|
. ' title="' . escapeHtml(implode("\n", $titles)) . '"></span>';
|
|
}
|
|
|
|
/** What needs looking at on a device, in words - a reboot, patches, failing checks. */
|
|
function rmmDeviceAttention(array $row): array
|
|
{
|
|
$items = [];
|
|
if (intval($row['rmm_device_needs_reboot'])) {
|
|
$items[] = ['icon' => 'fa-redo-alt', 'text' => 'Needs a reboot', 'tone' => 'warning'];
|
|
}
|
|
if (intval($row['rmm_device_patches_pending'])) {
|
|
$items[] = ['icon' => 'fa-download', 'text' => 'Patches waiting to install', 'tone' => 'warning'];
|
|
}
|
|
$failing = intval($row['rmm_device_checks_failing']);
|
|
if ($failing) {
|
|
$total = intval($row['rmm_device_checks_total']);
|
|
$items[] = ['icon' => 'fa-exclamation-triangle', 'text' => "$failing of $total check" . ($total === 1 ? '' : 's') . ' failing', 'tone' => 'danger'];
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/** How the assets list speaks of the RMMs - the one provider by name, or "an RMM" when there are more. */
|
|
function rmmListLabel(): string
|
|
{
|
|
$providers = rmmProviders();
|
|
return count($providers) === 1 ? rmmProviderName($providers[0]) : 'an RMM';
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ Linking clients
|
|
*/
|
|
|
|
/** A company name with the words that don't tell companies apart taken out - for suggesting a link. */
|
|
function rmmNameKey($name): string
|
|
{
|
|
$name = mb_strtolower(html_entity_decode((string) $name, ENT_QUOTES, 'UTF-8'));
|
|
$name = str_replace('&', ' and ', $name);
|
|
$name = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $name);
|
|
$words = array_filter(explode(' ', $name), function ($word) {
|
|
return $word !== '' && !in_array($word, ['the', 'llc', 'inc', 'incorporated', 'ltd', 'limited', 'co', 'corp', 'corporation', 'company', 'pllc', 'pc', 'llp', 'lp', 'plc', 'gmbh'], true);
|
|
});
|
|
return implode(' ', $words);
|
|
}
|
|
|
|
/** The ITFlow client each of a provider's unlinked clients looks like - rmm_client_id => client_id, where exactly one client has its name. */
|
|
function rmmClientSuggestions(int $provider_id): array
|
|
{
|
|
global $mysqli;
|
|
$by_key = [];
|
|
$sql = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$by_key[rmmNameKey($row['client_name'])][] = intval($row['client_id']);
|
|
}
|
|
$suggestions = [];
|
|
$sql = mysqli_query($mysqli, "SELECT rmm_client_id, rmm_client_name FROM rmm_clients WHERE rmm_client_provider_id = $provider_id AND rmm_client_client_id = 0 AND rmm_client_removed_at IS NULL");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$key = rmmNameKey($row['rmm_client_name']);
|
|
if ($key !== '' && count($by_key[$key] ?? []) === 1) {
|
|
$suggestions[intval($row['rmm_client_id'])] = $by_key[$key][0];
|
|
}
|
|
}
|
|
return $suggestions;
|
|
}
|
|
|
|
/** One rmm_clients row with its ITFlow client's name and its provider's columns - raw, or null. */
|
|
function rmmClientRow($rmm_client_id): ?array
|
|
{
|
|
global $mysqli;
|
|
$rmm_client_id = intval($rmm_client_id);
|
|
if (!$rmm_client_id || !rmmTablesReady()) {
|
|
return null;
|
|
}
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT rmm_clients.*, rmm_providers.*, client_name, client_archived_at FROM rmm_clients
|
|
JOIN rmm_providers ON rmm_provider_id = rmm_client_provider_id
|
|
LEFT JOIN clients ON client_id = rmm_client_client_id AND rmm_client_client_id > 0
|
|
WHERE rmm_client_id = $rmm_client_id LIMIT 1"));
|
|
return $row ?: null;
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------ The provider window
|
|
*/
|
|
|
|
/**
|
|
* What the provider window sent, checked - [$after, $errors]: the row to save, and what is
|
|
* wrong by field. A secret left empty keeps the saved one; a field its kind doesn't use keeps
|
|
* what it had. The kind is picked when it is added, and stays.
|
|
*/
|
|
function rmmProviderCheck(array $in, ?array $before): array
|
|
{
|
|
global $mysqli;
|
|
$errors = [];
|
|
$kind_key = $before ? (string) $before['rmm_provider_kind'] : (string) ($in['kind'] ?? '');
|
|
$kind = rmmKind($kind_key);
|
|
if (!$kind) {
|
|
return [[], ['kind' => 'Pick which RMM it is.']];
|
|
}
|
|
|
|
$name = rmmText($in['name'] ?? '', 100);
|
|
if ($name === '') {
|
|
$name = $kind['name'];
|
|
}
|
|
$id = intval($before['rmm_provider_id'] ?? 0);
|
|
$name_sql = escapeSql($name);
|
|
if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM rmm_providers WHERE rmm_provider_name = '$name_sql' AND rmm_provider_id <> $id LIMIT 1"))) {
|
|
$errors['name'] = 'Another provider is called that - give this one a name of its own, like ' . $kind['name'] . ' (second office).';
|
|
}
|
|
|
|
$after = [
|
|
'rmm_provider_kind' => $kind_key,
|
|
'rmm_provider_name' => $name,
|
|
'rmm_provider_active' => $before ? (!empty($in['active']) ? 1 : 0) : 1,
|
|
];
|
|
foreach (['api_url', 'web_url', 'api_key', 'api_secret'] as $field) {
|
|
$column = "rmm_provider_$field";
|
|
$had = (string) ($before[$column] ?? '');
|
|
$about = $kind['fields'][$field] ?? null;
|
|
if (!$about) {
|
|
$after[$column] = $had;
|
|
continue;
|
|
}
|
|
$typed = trim((string) ($in[$field] ?? ''));
|
|
if ($about['type'] === 'url') {
|
|
$clean = rmmUrlClean($typed);
|
|
if ($typed === '' && !empty($about['required'])) {
|
|
$errors[$field] = $about['needed'];
|
|
} elseif ($typed !== '' && ($clean === '' || mb_strlen($clean) > 500)) {
|
|
$errors[$field] = 'That isn\'t a web address ITFlow can use - just the address, like ' . $about['placeholder'] . '.';
|
|
}
|
|
$after[$column] = $clean;
|
|
} else {
|
|
if ($typed === '' && $had === '' && !empty($about['required'])) {
|
|
$errors[$field] = $about['needed'];
|
|
} elseif ($typed !== '' && (mb_strlen($typed) > 500 || preg_match('/\s/', $typed))) {
|
|
$errors[$field] = 'That doesn\'t look like ' . $kind['name'] . '\'s ' . rmmFieldWords($about['label']) . ' - paste just the ' . rmmFieldWords($about['label']) . '.';
|
|
}
|
|
$after[$column] = $typed !== '' ? $typed : $had;
|
|
}
|
|
}
|
|
|
|
// Alerts opening tickets - kept as they were when the window didn't send them (ticketing off)
|
|
if (rmmAlertsReady()) {
|
|
$level = (string) ($in['alert_level'] ?? ($before['rmm_provider_alert_level'] ?? ''));
|
|
if (!isset(rmmAlertLevels()[$level]) || ($level !== '' && !isset($kind['alerts'], $kind['alert']))) {
|
|
$errors['alert_level'] = 'Pick which alerts open tickets.';
|
|
$level = '';
|
|
}
|
|
$after['rmm_provider_alert_level'] = $level;
|
|
$after['rmm_provider_alert_category_id'] = ticketCategoryId($in['alert_category_id'] ?? ($before['rmm_provider_alert_category_id'] ?? 0));
|
|
// Turned on, at another level, or the provider back on - the alerts open then are taken as known, as at first
|
|
$after['rmm_provider_alerts_restart'] = $level !== '' && ($level !== (string) ($before['rmm_provider_alert_level'] ?? '')
|
|
|| ($before && !intval($before['rmm_provider_active']) && $after['rmm_provider_active']));
|
|
}
|
|
return [$after, $errors];
|
|
}
|
|
|
|
/** A field's label inside a sentence - "dashboard address", but "API key" stays as it is. */
|
|
function rmmFieldWords(string $label): string
|
|
{
|
|
return preg_match('/^[A-Z]{2}/', $label) ? $label : lcfirst($label);
|
|
}
|
|
|
|
/** Save a provider - a new row when $provider_id is 0. Its id. */
|
|
function rmmProviderWrite(array $after, int $provider_id = 0): int
|
|
{
|
|
global $mysqli;
|
|
$set = [];
|
|
foreach (['rmm_provider_name', 'rmm_provider_api_url', 'rmm_provider_web_url', 'rmm_provider_api_key', 'rmm_provider_api_secret'] as $column) {
|
|
$set[] = "$column = '" . escapeSql($after[$column]) . "'";
|
|
}
|
|
$set[] = 'rmm_provider_active = ' . intval($after['rmm_provider_active']);
|
|
if (array_key_exists('rmm_provider_alert_level', $after)) {
|
|
$set[] = "rmm_provider_alert_level = '" . escapeSql($after['rmm_provider_alert_level']) . "'";
|
|
$set[] = 'rmm_provider_alert_category_id = ' . intval($after['rmm_provider_alert_category_id']);
|
|
if (!empty($after['rmm_provider_alerts_restart'])) {
|
|
$set[] = 'rmm_provider_alerts_since = NULL, rmm_provider_alerts_note = NULL';
|
|
}
|
|
}
|
|
if ($provider_id) {
|
|
mysqli_query($mysqli, "UPDATE rmm_providers SET " . implode(', ', $set) . " WHERE rmm_provider_id = $provider_id");
|
|
return $provider_id;
|
|
}
|
|
$set[] = "rmm_provider_kind = '" . escapeSql($after['rmm_provider_kind']) . "'";
|
|
mysqli_query($mysqli, "INSERT INTO rmm_providers SET " . implode(', ', $set));
|
|
return intval(mysqli_insert_id($mysqli));
|
|
}
|
|
|
|
/** What changed on a provider, for the audit log - secrets only as changed. */
|
|
function rmmProviderChangeLines(array $before, array $after): array
|
|
{
|
|
$kind = rmmKind($before);
|
|
$lines = [];
|
|
if ($before['rmm_provider_name'] !== $after['rmm_provider_name']) {
|
|
$lines[] = 'renamed ' . $after['rmm_provider_name'];
|
|
}
|
|
foreach ($kind['fields'] ?? [] as $field => $about) {
|
|
$column = "rmm_provider_$field";
|
|
if ((string) $before[$column] === (string) $after[$column]) {
|
|
continue;
|
|
}
|
|
$label = rmmFieldWords($about['label']);
|
|
if ($about['type'] === 'secret') {
|
|
$lines[] = "$label changed";
|
|
} else {
|
|
$lines[] = $after[$column] === '' ? "$label cleared" : "$label " . $after[$column];
|
|
}
|
|
}
|
|
if (intval($before['rmm_provider_active']) !== intval($after['rmm_provider_active'])) {
|
|
$lines[] = intval($after['rmm_provider_active']) ? 'turned on' : 'turned off';
|
|
}
|
|
if (array_key_exists('rmm_provider_alert_level', $after) && (string) ($before['rmm_provider_alert_level'] ?? '') !== $after['rmm_provider_alert_level']) {
|
|
$lines[] = $after['rmm_provider_alert_level'] === '' ? 'alerts no longer open tickets' : 'alerts open tickets: ' . mb_strtolower(rmmAlertLevels()[$after['rmm_provider_alert_level']]);
|
|
}
|
|
if (array_key_exists('rmm_provider_alert_category_id', $after) && intval($before['rmm_provider_alert_category_id'] ?? 0) !== intval($after['rmm_provider_alert_category_id'])) {
|
|
$category = intval($after['rmm_provider_alert_category_id']) ? (string) getFieldById('categories', intval($after['rmm_provider_alert_category_id']), 'category_name') : '';
|
|
$lines[] = $category === '' ? 'alert tickets not categorized' : "alert tickets in $category";
|
|
}
|
|
return $lines;
|
|
}
|