Files
itflow/functions/rmm.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;
}