mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
1418 lines
68 KiB
PHP
1418 lines
68 KiB
PHP
<?php
|
|
|
|
/*
|
|
* ITFlow - Managed mode: one ITFlow managing others
|
|
*
|
|
* Any ITFlow can be a MANAGER (Admin > Managed Instances) and any can be MANAGED (Admin >
|
|
* Managed By) - or both, for a reseller managed by its provider.
|
|
*
|
|
* The instance does all the talking. Connecting it uses a one-time code from the manager;
|
|
* from then on its Manager Check-in cron job calls the manager every minute with a health
|
|
* report, and gets back what the manager sets for it (seat limit, white-label key, updates,
|
|
* active, read-only or suspended) and anything it has asked (update now, check for updates).
|
|
* The manager never has to reach an instance, so instances can sit behind NAT and firewalls.
|
|
*
|
|
* Support sign-in is the one thing that goes the other way, through the browser: the manager
|
|
* signs a two-minute link with its own key (managerSign()) and the instance checks it with the
|
|
* public half it was given when it connected - so an instance's database can't be used to sign
|
|
* anyone into any other instance. The person signs in as the instance's support user, which
|
|
* takes no seat, has no password and can't open the credential vault.
|
|
*
|
|
* On a managed instance, what the manager sets is locked: seat limit, white label and updates.
|
|
* Everything else stays the instance's own. $config_manager_required = true in config.php
|
|
* stops its admins disconnecting it - for a provider hosting it.
|
|
*
|
|
* Both sides are off until config.php turns them on (managerEnabled(), managedEnabled()), and
|
|
* a side that's off isn't there at all - no pages, menu entries, cron jobs or endpoints, nothing
|
|
* locked and no support sign-in.
|
|
*/
|
|
|
|
/* What each mode means - read-only lets people look, suspended shuts the doors */
|
|
const MANAGED_MODES = ['active' => 'Active', 'read_only' => 'Read-only', 'suspended' => 'Suspended'];
|
|
|
|
/* A connection code works for a week */
|
|
const MANAGED_ENROLL_DAYS = 7;
|
|
|
|
/* No check-in for this long and the manager calls an instance offline */
|
|
const MANAGED_OFFLINE_MINUTES = 10;
|
|
|
|
/* A support sign-in link works this long; the session it opens, this long */
|
|
const MANAGED_SUPPORT_LINK_SECONDS = 120;
|
|
const MANAGED_SUPPORT_SESSION_HOURS = 2;
|
|
|
|
/* The cron jobs that keep running while an instance is read-only or suspended */
|
|
const MANAGED_KEEP_JOBS = [
|
|
'read_only' => ['manager_checkin', 'update_check', 'app_update', 'backup', 'mail_queue'],
|
|
'suspended' => ['manager_checkin', 'update_check', 'app_update', 'backup'],
|
|
];
|
|
|
|
/* ------------------------------------------------------------------- Both sides */
|
|
|
|
/*
|
|
* Whether config.php turns each side on - both are off unless it does:
|
|
*
|
|
* $config_enable_managed_instances = true; this ITFlow manages others (Admin > Managed Instances,
|
|
* api/managed/, the Managed Instances cron job)
|
|
* $config_enable_managed_by = true; another ITFlow can manage this one (Admin > Managed By,
|
|
* the Manager Check-in cron job, support sign-in, locks)
|
|
*
|
|
* $config_manager_required = true (managedRequired()) turns the second on as well. Turning a side
|
|
* off keeps what it holds: an instance stays connected but stops checking in, and its manager's
|
|
* locks, read-only and suspended lift; a manager keeps its instances, which are told it isn't
|
|
* answering and keep trying.
|
|
*/
|
|
function managerEnabled(): bool
|
|
{
|
|
global $config_enable_managed_instances;
|
|
return !empty($config_enable_managed_instances);
|
|
}
|
|
|
|
function managedEnabled(): bool
|
|
{
|
|
global $config_enable_managed_by;
|
|
return !empty($config_enable_managed_by) || managedRequired();
|
|
}
|
|
|
|
function managedBase64UrlEncode(string $data): string
|
|
{
|
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
}
|
|
|
|
function managedBase64UrlDecode(string $data): string
|
|
{
|
|
return (string) base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4), true);
|
|
}
|
|
|
|
/* A random UUID (v4) - an instance's name to its manager */
|
|
function managedUuid(): string
|
|
{
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
|
}
|
|
|
|
/*
|
|
* This install's address - config.php's $config_base_url, over https unless config.php allows
|
|
* plain http and this request came that way. Kept by the instance when it connects, because
|
|
* cron has no request.
|
|
*/
|
|
function managedRequestBaseUrl(): string
|
|
{
|
|
global $config_base_url, $config_https_only;
|
|
|
|
$https = !isset($config_https_only) || $config_https_only
|
|
|| (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
|
|| strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'
|
|
|| strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? '')) === 'https';
|
|
|
|
return ($https ? 'https' : 'http') . '://' . rtrim((string) $config_base_url, '/');
|
|
}
|
|
|
|
/* POST JSON and read JSON back - ['ok', 'code', 'json', 'err'] */
|
|
function managedHttpJson(string $url, array $body, array $headers = []): array
|
|
{
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($body),
|
|
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json', 'Accept: application/json'], $headers),
|
|
CURLOPT_TIMEOUT => 20,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
CURLOPT_USERAGENT => 'ITFlow managed mode',
|
|
// An http address moved to https still gets the POST, not a GET
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_MAXREDIRS => 3,
|
|
CURLOPT_POSTREDIR => CURL_REDIR_POST_ALL,
|
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
|
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
|
]);
|
|
$raw = curl_exec($ch);
|
|
$err = curl_error($ch);
|
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
$json = is_string($raw) ? json_decode($raw, true) : null;
|
|
|
|
return ['ok' => $raw !== false && $code >= 200 && $code < 300 && is_array($json), 'code' => $code, 'json' => is_array($json) ? $json : [], 'err' => $err];
|
|
}
|
|
|
|
/* ------------------------------------------------- Managed instance (this ITFlow) */
|
|
|
|
/*
|
|
* What this ITFlow knows about its manager (settings.config_managed) - [] when it isn't
|
|
* managed, or its database hasn't caught up with managed mode yet. Read once a request.
|
|
*
|
|
* manager_url, manager_name, public_key who manages it, and the key it signs with
|
|
* uuid, secret how it identifies itself when it checks in
|
|
* self_url its own address, as it was when it connected
|
|
* state what the manager last set (managedState())
|
|
* support_user_id the user support signs in as
|
|
* last_checkin_at, last_error how the last check-in went
|
|
* acks answers to commands, sent with the next check-in
|
|
* metrics disk and database size, measured every half hour
|
|
* white_label_applied the key the manager put in, if it did
|
|
* nonces support links already used, until they expire
|
|
*/
|
|
function managedConfig(bool $refresh = false): array
|
|
{
|
|
global $mysqli;
|
|
static $config = null;
|
|
|
|
if ($config === null || $refresh) {
|
|
$config = [];
|
|
try {
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_managed FROM settings WHERE company_id = 1"));
|
|
$decoded = json_decode((string) ($row[0] ?? ''), true);
|
|
$config = is_array($decoded) ? $decoded : [];
|
|
} catch (Throwable $e) {
|
|
// The column arrives with a migration - before it, nothing is managed
|
|
$config = [];
|
|
}
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
function managedConfigSave(array $config): void
|
|
{
|
|
global $mysqli;
|
|
|
|
$json = $config ? mysqli_real_escape_string($mysqli, (string) json_encode($config)) : '';
|
|
mysqli_query($mysqli, "UPDATE settings SET config_managed = " . ($json === '' ? 'NULL' : "'$json'") . " WHERE company_id = 1");
|
|
managedConfig(true);
|
|
}
|
|
|
|
/* Managed - connected to a manager, with config.php's Managed By on (managedEnabled()) */
|
|
function managedIsManaged(): bool
|
|
{
|
|
return managedEnabled() && managedConnected();
|
|
}
|
|
|
|
/* Connected to a manager, whether or not config.php has Managed By on */
|
|
function managedConnected(): bool
|
|
{
|
|
$config = managedConfig();
|
|
return !empty($config['manager_url']) && !empty($config['uuid']) && !empty($config['secret']);
|
|
}
|
|
|
|
/* What the manager set, with nothing missing */
|
|
function managedState(): array
|
|
{
|
|
$state = managedIsManaged() ? (array) (managedConfig()['state'] ?? []) : [];
|
|
|
|
return [
|
|
'mode' => isset(MANAGED_MODES[$state['mode'] ?? '']) ? $state['mode'] : 'active',
|
|
'message' => (string) ($state['message'] ?? ''),
|
|
'seat_limit' => max(0, intval($state['seat_limit'] ?? 0)),
|
|
'white_label_key' => isset($state['white_label_key']) && is_string($state['white_label_key']) ? $state['white_label_key'] : null,
|
|
'update_policy' => ($state['update_policy'] ?? '') === 'auto' ? 'auto' : 'manual',
|
|
'update_delay_days' => max(0, intval($state['update_delay_days'] ?? 7)),
|
|
'manager_name' => (string) ($state['manager_name'] ?? (managedConfig()['manager_name'] ?? '')),
|
|
'contact' => (string) ($state['contact'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/* active, read_only or suspended - active unless a manager says otherwise */
|
|
function managedMode(): string
|
|
{
|
|
return managedState()['mode'];
|
|
}
|
|
|
|
function managedManagerName(): string
|
|
{
|
|
$name = managedState()['manager_name'];
|
|
return $name !== '' ? $name : 'its manager';
|
|
}
|
|
|
|
/* Whether config.php keeps this install managed - its admins can't disconnect it */
|
|
function managedRequired(): bool
|
|
{
|
|
global $config_manager_required;
|
|
return !empty($config_manager_required);
|
|
}
|
|
|
|
/* The seat limit the manager set - 0 for none, when config.php's applies (getAgentUserLimit()) */
|
|
function managedSeatLimit(): int
|
|
{
|
|
return managedState()['seat_limit'];
|
|
}
|
|
|
|
/* What the manager has locked here */
|
|
function managedLocks(): array
|
|
{
|
|
$managed = managedIsManaged();
|
|
$state = managedState();
|
|
|
|
return [
|
|
'seat_limit' => $managed && $state['seat_limit'] > 0,
|
|
'white_label' => $managed && $state['white_label_key'] !== null,
|
|
'updates' => $managed,
|
|
];
|
|
}
|
|
|
|
/* The support user - while connected, even with Managed By off, so it never takes a seat */
|
|
function managedSupportUserId(): int
|
|
{
|
|
return managedConnected() ? intval(managedConfig()['support_user_id'] ?? 0) : 0;
|
|
}
|
|
|
|
/* A manager's connection code - ITFLOW-MANAGE:<base64url {"u": manager address, "t": token}> - read, or null */
|
|
function managedParseCode(string $code): ?array
|
|
{
|
|
$code = preg_replace('/\s+/', '', $code);
|
|
if (!preg_match('/^ITFLOW-MANAGE:([A-Za-z0-9_-]+)$/', $code, $match)) {
|
|
return null;
|
|
}
|
|
$data = json_decode(managedBase64UrlDecode($match[1]), true);
|
|
if (!is_array($data) || !is_string($data['u'] ?? null) || !is_string($data['t'] ?? null)
|
|
|| !preg_match('#^https?://[^\s/]+(/[^\s]*)?$#', $data['u']) || !preg_match('/^[a-f0-9]{64}$/', $data['t'])) {
|
|
return null;
|
|
}
|
|
return ['manager_url' => rtrim($data['u'], '/'), 'token' => $data['t']];
|
|
}
|
|
|
|
/*
|
|
* Connect this ITFlow to the manager a code is from. The code works once: the manager hands
|
|
* back this instance's id and secret, its name and the public key it signs support sign-ins
|
|
* with. Then a first check-in, so the page shows the answer straight away.
|
|
*/
|
|
function managedEnroll(string $code, string $self_url, &$error = ''): bool
|
|
{
|
|
global $mysqli;
|
|
|
|
if (!managedEnabled()) {
|
|
$error = 'Managed By is off - add $config_enable_managed_by = true; to config.php first.';
|
|
return false;
|
|
}
|
|
|
|
$parsed = managedParseCode($code);
|
|
if (!$parsed) {
|
|
$error = "That isn't a connection code - copy it again from the manager's Managed Instances page. It starts ITFLOW-MANAGE:";
|
|
return false;
|
|
}
|
|
|
|
require_once __DIR__ . '/../includes/app_version.php';
|
|
|
|
$company = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"))[0] ?? '');
|
|
$response = managedHttpJson($parsed['manager_url'] . '/api/managed/enroll.php', [
|
|
'token' => $parsed['token'],
|
|
'url' => $self_url,
|
|
'company' => $company,
|
|
'version' => APP_VERSION,
|
|
]);
|
|
|
|
if (!$response['ok']) {
|
|
$error = $response['json']['error'] ?? ($response['code'] ? "The manager answered " . $response['code'] . "." : "The manager couldn't be reached - " . ($response['err'] ?: 'no answer') . '.');
|
|
return false;
|
|
}
|
|
|
|
$json = $response['json'];
|
|
if (!preg_match('/^[a-f0-9-]{36}$/', (string) ($json['uuid'] ?? '')) || !preg_match('/^[a-f0-9]{64}$/', (string) ($json['secret'] ?? ''))
|
|
|| !str_contains((string) ($json['manager']['public_key'] ?? ''), 'BEGIN PUBLIC KEY')) {
|
|
$error = "The manager's answer didn't make sense - is it running the same version of ITFlow?";
|
|
return false;
|
|
}
|
|
|
|
managedConfigSave([
|
|
'manager_url' => $parsed['manager_url'],
|
|
'manager_name' => (string) ($json['manager']['name'] ?? ''),
|
|
'public_key' => (string) $json['manager']['public_key'],
|
|
'uuid' => $json['uuid'],
|
|
'secret' => $json['secret'],
|
|
'self_url' => rtrim($self_url, '/'),
|
|
'connected_at' => date('Y-m-d H:i:s'),
|
|
'state' => is_array($json['state'] ?? null) ? $json['state'] : [],
|
|
]);
|
|
managedApplyState();
|
|
|
|
managedCheckin();
|
|
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
* Leave the manager. Its support user is archived, which ends any support session, and a
|
|
* white-label key the manager put in comes out. The manager is told, best effort.
|
|
*/
|
|
function managedDisconnect(string $why = 'disconnected'): void
|
|
{
|
|
global $mysqli;
|
|
|
|
$config = managedConfig();
|
|
if (!$config) {
|
|
return;
|
|
}
|
|
|
|
if ($why === 'disconnected' && !empty($config['manager_url'])) {
|
|
managedHttpJson($config['manager_url'] . '/api/managed/checkin.php', ['disconnect' => true, 'auth' => ($config['uuid'] ?? '') . ':' . ($config['secret'] ?? '')],
|
|
['Authorization: Bearer ' . ($config['uuid'] ?? '') . ':' . ($config['secret'] ?? '')]);
|
|
}
|
|
|
|
$support_user_id = intval($config['support_user_id'] ?? 0);
|
|
if ($support_user_id) {
|
|
mysqli_query($mysqli, "UPDATE users SET user_archived_at = NOW(), user_status = 0 WHERE user_id = $support_user_id AND user_archived_at IS NULL");
|
|
}
|
|
|
|
if (!empty($config['white_label_applied'])) {
|
|
$applied = mysqli_real_escape_string($mysqli, (string) $config['white_label_applied']);
|
|
mysqli_query($mysqli, "UPDATE settings SET config_whitelabel_key = '', config_whitelabel_enabled = 0 WHERE company_id = 1 AND config_whitelabel_key = '$applied'");
|
|
}
|
|
|
|
managedConfigSave([]);
|
|
}
|
|
|
|
/*
|
|
* Put what the manager set into effect where it lives elsewhere. The seat limit and mode are
|
|
* read from managedState() where they're used; the white-label key goes where Admin >
|
|
* Settings > Modules keeps it, as it is the manager's.
|
|
*/
|
|
function managedApplyState(): void
|
|
{
|
|
global $mysqli;
|
|
|
|
$config = managedConfig();
|
|
$key = managedState()['white_label_key'];
|
|
|
|
if ($key !== null && $key !== ($config['white_label_applied'] ?? null)) {
|
|
$valid = $key !== '' && validateWhitelabelKey($key);
|
|
$key_sql = mysqli_real_escape_string($mysqli, $key);
|
|
mysqli_query($mysqli, "UPDATE settings SET config_whitelabel_key = '$key_sql', config_whitelabel_enabled = " . ($valid ? 1 : 0) . " WHERE company_id = 1");
|
|
$config['white_label_applied'] = $key;
|
|
managedConfigSave($config);
|
|
} elseif ($key === null && !empty($config['white_label_applied'])) {
|
|
// The manager stopped setting one - its key comes out, and the field is the instance's again
|
|
$applied = mysqli_real_escape_string($mysqli, (string) $config['white_label_applied']);
|
|
mysqli_query($mysqli, "UPDATE settings SET config_whitelabel_key = '', config_whitelabel_enabled = 0 WHERE company_id = 1 AND config_whitelabel_key = '$applied'");
|
|
unset($config['white_label_applied']);
|
|
managedConfigSave($config);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* A time from this install's clock, with its offset (2026-11-05T14:55:00-05:00) - the manager
|
|
* may keep another time zone, and reads it right either way. null stays null.
|
|
*/
|
|
function managedIsoTime($datetime): ?string
|
|
{
|
|
$time = $datetime ? strtotime((string) $datetime) : false;
|
|
return $time ? date('c', $time) : null;
|
|
}
|
|
|
|
/* A directory's size in MB - du when it can be run, else walked */
|
|
function managedDirMb(string $dir): float
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return 0.0;
|
|
}
|
|
if (function_exists('shellCommandsAvailable') && shellCommandsAvailable()) {
|
|
$out = trim((string) shell_exec('du -sk ' . escapeshellarg($dir) . ' 2>/dev/null'));
|
|
if (preg_match('/^(\d+)/', $out, $match)) {
|
|
return round(intval($match[1]) / 1024, 1);
|
|
}
|
|
}
|
|
$bytes = 0;
|
|
try {
|
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $file) {
|
|
$bytes += $file->getSize();
|
|
}
|
|
} catch (Throwable $e) {
|
|
// Unreadable in part - what was read is still something
|
|
}
|
|
return round($bytes / 1048576, 1);
|
|
}
|
|
|
|
/*
|
|
* How this ITFlow is doing, for its manager - versions, updates, cron, backups, errors,
|
|
* agents against the seat limit, and disk and database size (measured every half hour,
|
|
* as walking the uploads folder isn't free). Times carry their offset (managedIsoTime()).
|
|
*/
|
|
function managedReport(): array
|
|
{
|
|
global $mysqli;
|
|
|
|
require_once __DIR__ . '/../includes/app_version.php';
|
|
require_once __DIR__ . '/../includes/database_version.php';
|
|
|
|
$config = managedConfig();
|
|
$settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_current_database_version, config_enable_cron, config_cron_last_dispatch_at,
|
|
config_update_latest_commit, config_update_pending_commits, config_update_checked_at, config_update_details FROM settings WHERE company_id = 1")) ?: [];
|
|
|
|
$current_commit = gitCurrentCommit();
|
|
$latest_commit = (string) ($settings['config_update_latest_commit'] ?? '');
|
|
$details = json_decode((string) ($settings['config_update_details'] ?? ''), true) ?: [];
|
|
$pending = json_decode((string) ($settings['config_update_pending_commits'] ?? ''), true) ?: [];
|
|
$security = false;
|
|
foreach ((array) ($details['changelog'] ?? []) as $section) {
|
|
if (is_array($section) && preg_match('/^###\s+Security/mi', (string) ($section[1] ?? ''))) {
|
|
$security = true;
|
|
}
|
|
}
|
|
$release = (string) ($details['version'] ?? '');
|
|
|
|
$app_update = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_last_status, cron_job_last_finished_at, cron_job_last_error, cron_job_last_error_at
|
|
FROM cron_jobs WHERE cron_job_name = 'app_update' LIMIT 1")) ?: [];
|
|
$backup = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT backup_status, backup_created_at, backup_completed_at FROM backups
|
|
WHERE backup_status IN ('Complete', 'Failed') ORDER BY backup_created_at DESC LIMIT 1")) ?: [];
|
|
$backup_ok = mysqli_fetch_row(mysqli_query($mysqli, "SELECT MAX(backup_completed_at) FROM backups WHERE backup_status = 'Complete'"))[0] ?? null;
|
|
$errors = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM app_logs WHERE app_log_type = 'error' AND app_log_created_at > NOW() - INTERVAL 1 DAY"))[0] ?? 0);
|
|
$clients = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM clients WHERE client_archived_at IS NULL AND client_lead = 0"))[0] ?? 0);
|
|
$company = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"))[0] ?? '');
|
|
|
|
// Disk and database size, every half hour
|
|
$metrics = (array) ($config['metrics'] ?? []);
|
|
if (empty($metrics['measured_at']) || strtotime($metrics['measured_at']) < strtotime('-30 minutes')) {
|
|
$db_mb = mysqli_fetch_row(mysqli_query($mysqli, "SELECT ROUND(SUM(data_length + index_length) / 1048576, 1) FROM information_schema.TABLES WHERE table_schema = DATABASE()"))[0] ?? 0;
|
|
$metrics = ['uploads_mb' => managedDirMb(dirname(__DIR__) . '/uploads'), 'db_mb' => floatval($db_mb), 'measured_at' => date('Y-m-d H:i:s')];
|
|
$config['metrics'] = $metrics;
|
|
managedConfigSave($config);
|
|
}
|
|
|
|
return [
|
|
'url' => (string) ($config['self_url'] ?? ''),
|
|
'company' => $company,
|
|
'reported_at' => date('c'),
|
|
'timezone' => date_default_timezone_get(),
|
|
'app' => [
|
|
'version' => APP_VERSION,
|
|
'commit' => substr($current_commit, 0, 7),
|
|
'branch' => getRepoBranch(),
|
|
'db_version' => (string) ($settings['config_current_database_version'] ?? ''),
|
|
'db_latest' => LATEST_DATABASE_VERSION,
|
|
'php' => PHP_VERSION,
|
|
],
|
|
'update' => [
|
|
'checked_at' => managedIsoTime($settings['config_update_checked_at'] ?? null),
|
|
'available' => $latest_commit !== '' && $current_commit !== '' && $latest_commit !== $current_commit,
|
|
'release' => $release !== '' && version_compare($release, APP_VERSION, '>') ? $release : '',
|
|
'latest_commit' => substr($latest_commit, 0, 7),
|
|
'commits' => count($pending),
|
|
'security' => $security,
|
|
'last' => [
|
|
'status' => (string) ($app_update['cron_job_last_status'] ?? ''),
|
|
'finished_at' => managedIsoTime($app_update['cron_job_last_finished_at'] ?? null),
|
|
'error' => ($app_update['cron_job_last_status'] ?? '') === 'Failed' ? (string) ($app_update['cron_job_last_error'] ?? '') : '',
|
|
],
|
|
],
|
|
'cron' => [
|
|
'enabled' => !empty($settings['config_enable_cron']),
|
|
'last_dispatch_at' => managedIsoTime($settings['config_cron_last_dispatch_at'] ?? null),
|
|
// Its cron job - with the PHP that runs this when that's the command line (setup, or cron itself)
|
|
'command' => (PHP_SAPI === 'cli' && PHP_BINARY !== '' && !preg_match('/\s/', PHP_BINARY) ? PHP_BINARY : 'php') . ' ' . dirname(__DIR__) . '/cron/cron.php',
|
|
],
|
|
'backup' => [
|
|
'last_status' => (string) ($backup['backup_status'] ?? ''),
|
|
'last_at' => managedIsoTime($backup['backup_completed_at'] ?? ($backup['backup_created_at'] ?? null)),
|
|
'last_ok_at' => managedIsoTime($backup_ok),
|
|
],
|
|
'errors_24h' => $errors,
|
|
'agents' => [
|
|
'active' => getActiveAgentUserCount(),
|
|
'invites' => function_exists('userInvitesHoldingSeats') ? userInvitesHoldingSeats() : 0,
|
|
'limit' => getAgentUserLimit(),
|
|
],
|
|
'clients' => $clients,
|
|
'disk' => ['measured_at' => managedIsoTime($metrics['measured_at'] ?? null)] + $metrics,
|
|
'mode' => managedMode(),
|
|
];
|
|
}
|
|
|
|
/*
|
|
* Check in with the manager - the Manager Check-in job's work, and done once on connecting.
|
|
* Sends the report and the answers to last time's commands; takes back what the manager sets
|
|
* and anything it asks. A manager that has removed this instance answers 410, and the
|
|
* instance lets go. ['ok', 'error', 'commands' => how many it ran].
|
|
*/
|
|
function managedCheckin(): array
|
|
{
|
|
$config = managedConfig();
|
|
if (!managedIsManaged()) {
|
|
return ['ok' => false, 'error' => 'Not managed', 'commands' => 0];
|
|
}
|
|
|
|
$report = managedReport();
|
|
$config = managedConfig(true);
|
|
$report['acks'] = array_values((array) ($config['acks'] ?? []));
|
|
// In the body as well as the header - some web servers drop Authorization before PHP sees it
|
|
$report['auth'] = $config['uuid'] . ':' . $config['secret'];
|
|
|
|
$response = managedHttpJson($config['manager_url'] . '/api/managed/checkin.php', $report,
|
|
['Authorization: Bearer ' . $config['uuid'] . ':' . $config['secret']]);
|
|
|
|
if ($response['code'] === 410 && !empty($response['json']['released'])) {
|
|
managedDisconnect('released');
|
|
appNotify("Management", "This ITFlow is no longer managed - " . escapeSql(managedManagerNameFrom($config)) . " removed it", "/admin/managed_by.php");
|
|
logAudit("Management", "Release", "The manager removed this instance, so it is no longer managed");
|
|
return ['ok' => true, 'error' => '', 'commands' => 0];
|
|
}
|
|
|
|
if (!$response['ok']) {
|
|
$config['last_error'] = $response['json']['error'] ?? ($response['code'] ? "The manager answered " . $response['code'] : "The manager couldn't be reached - " . ($response['err'] ?: 'no answer'));
|
|
$config['last_error_at'] = date('Y-m-d H:i:s');
|
|
managedConfigSave($config);
|
|
return ['ok' => false, 'error' => $config['last_error'], 'commands' => 0];
|
|
}
|
|
|
|
$json = $response['json'];
|
|
$was_mode = managedMode();
|
|
$config['state'] = is_array($json['state'] ?? null) ? $json['state'] : ($config['state'] ?? []);
|
|
if (!empty($config['state']['manager_name'])) {
|
|
$config['manager_name'] = $config['state']['manager_name'];
|
|
}
|
|
$config['last_checkin_at'] = date('Y-m-d H:i:s');
|
|
unset($config['last_error'], $config['last_error_at']);
|
|
$config['acks'] = [];
|
|
managedConfigSave($config);
|
|
managedApplyState();
|
|
|
|
$now_mode = managedMode();
|
|
if ($now_mode !== $was_mode) {
|
|
logAudit("Management", "Mode", escapeSql(managedManagerName() . " set this ITFlow " . strtolower(MANAGED_MODES[$now_mode])));
|
|
}
|
|
|
|
// Commands - each answered on the next check-in
|
|
$ran = 0;
|
|
$acks = [];
|
|
foreach ((array) ($json['commands'] ?? []) as $command) {
|
|
if (!is_array($command) || !intval($command['id'] ?? 0)) {
|
|
continue;
|
|
}
|
|
$acks[] = ['id' => intval($command['id'])] + managedRunCommand((string) ($command['command'] ?? ''));
|
|
$ran++;
|
|
}
|
|
if ($acks) {
|
|
$config = managedConfig(true);
|
|
$config['acks'] = $acks;
|
|
managedConfigSave($config);
|
|
}
|
|
|
|
return ['ok' => true, 'error' => '', 'commands' => $ran];
|
|
}
|
|
|
|
function managedManagerNameFrom(array $config): string
|
|
{
|
|
$name = (string) ($config['state']['manager_name'] ?? ($config['manager_name'] ?? ''));
|
|
return $name !== '' ? $name : 'its manager';
|
|
}
|
|
|
|
/*
|
|
* Do what the manager asked - ['ok', 'message']. The update goes through the same queue as
|
|
* Maintenance > Update's button, so cron/app_update.php runs it in its own process and the
|
|
* outcome shows in the next report.
|
|
*/
|
|
function managedRunCommand(string $command): array
|
|
{
|
|
global $mysqli;
|
|
|
|
switch ($command) {
|
|
case 'update_now':
|
|
if (!settingsColumnExists($mysqli, 'config_update_queued_at')) {
|
|
return ['ok' => false, 'message' => 'This install has to be updated from a shell once - its database is too far behind to queue updates'];
|
|
}
|
|
mysqli_query($mysqli, "UPDATE settings SET config_update_queued_at = '" . date('Y-m-d H:i:s') . "' WHERE company_id = 1");
|
|
mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_name = 'app_update'");
|
|
logAudit("App", "Update", escapeSql(managedManagerName() . " queued an update to be applied by cron"));
|
|
return ['ok' => true, 'message' => 'Update started'];
|
|
case 'check_updates':
|
|
mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_name = 'update_check'");
|
|
return ['ok' => true, 'message' => 'Checked for updates'];
|
|
}
|
|
|
|
return ['ok' => false, 'message' => "Not something this version of ITFlow can do ($command)"];
|
|
}
|
|
|
|
/*
|
|
* A support sign-in link's payload, checked - signed by the manager, for this instance, in
|
|
* date and not used before - or null, with $error.
|
|
*/
|
|
function managedVerifySupportToken(string $token, &$error = ''): ?array
|
|
{
|
|
$config = managedConfig();
|
|
if (!managedEnabled()) {
|
|
$error = 'This ITFlow takes no support sign-ins - managed mode is off here.';
|
|
return null;
|
|
}
|
|
if (!managedIsManaged() || empty($config['public_key'])) {
|
|
$error = 'This ITFlow is not managed, so it takes no support sign-ins.';
|
|
return null;
|
|
}
|
|
|
|
$parts = explode('.', $token);
|
|
if (count($parts) !== 2) {
|
|
$error = "That isn't a support sign-in link.";
|
|
return null;
|
|
}
|
|
[$body, $signature] = $parts;
|
|
$public_key = openssl_pkey_get_public((string) $config['public_key']);
|
|
if (!$public_key || openssl_verify($body, managedBase64UrlDecode($signature), $public_key, OPENSSL_ALGO_SHA256) !== 1) {
|
|
$error = "That link wasn't signed by this ITFlow's manager.";
|
|
return null;
|
|
}
|
|
|
|
$payload = json_decode(managedBase64UrlDecode($body), true);
|
|
if (!is_array($payload) || ($payload['i'] ?? '') !== $config['uuid']) {
|
|
$error = 'That link is for another ITFlow.';
|
|
return null;
|
|
}
|
|
$expires = intval($payload['x'] ?? 0);
|
|
// A minute's grace either way, for two servers' clocks
|
|
if ($expires < time() - 60 || $expires > time() + MANAGED_SUPPORT_LINK_SECONDS + 60) {
|
|
$error = 'That link has run out - sign in from the manager again.';
|
|
return null;
|
|
}
|
|
$nonce = (string) ($payload['j'] ?? '');
|
|
$nonces = array_filter((array) ($config['nonces'] ?? []), function ($until) {
|
|
return $until > time();
|
|
});
|
|
if (!preg_match('/^[a-f0-9]{32}$/', $nonce) || isset($nonces[$nonce])) {
|
|
$error = 'That link has been used - sign in from the manager again.';
|
|
return null;
|
|
}
|
|
|
|
$nonces[$nonce] = $expires + 120;
|
|
$config['nonces'] = $nonces;
|
|
managedConfigSave($config);
|
|
|
|
return [
|
|
'name' => mb_substr(trim((string) ($payload['n'] ?? '')), 0, 120),
|
|
'email' => mb_substr(trim((string) ($payload['e'] ?? '')), 0, 200),
|
|
];
|
|
}
|
|
|
|
/*
|
|
* The user support signs in as - made on the first sign-in, and kept an active admin while
|
|
* the instance is managed (signing in again undoes an archive). No password anyone knows, no
|
|
* MFA (the manager's own sign-in stands for it), no seat. Its id, or 0.
|
|
*/
|
|
function managedSupportUser(): int
|
|
{
|
|
global $mysqli;
|
|
|
|
$config = managedConfig();
|
|
$manager = managedManagerNameFrom($config);
|
|
$user_id = intval($config['support_user_id'] ?? 0);
|
|
$role_id = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT role_id FROM user_roles WHERE role_is_admin = 1 AND role_archived_at IS NULL ORDER BY role_id ASC LIMIT 1"))[0] ?? 0);
|
|
if (!$role_id) {
|
|
return 0;
|
|
}
|
|
|
|
$name = mysqli_real_escape_string($mysqli, mb_substr("Support - $manager", 0, 200));
|
|
$host = preg_replace('/[^a-z0-9.-]/', '', strtolower((string) parse_url((string) $config['manager_url'], PHP_URL_HOST))) ?: 'manager';
|
|
$email = mysqli_real_escape_string($mysqli, "managed-support@$host.invalid");
|
|
|
|
$exists = $user_id ? mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_id = $user_id AND user_type = 1")) : null;
|
|
if (!$exists) {
|
|
// Connected again after a disconnect - the support user from before, rather than a second one
|
|
$exists = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_email = '$email' AND user_type = 1 ORDER BY user_id DESC LIMIT 1"));
|
|
$user_id = intval($exists[0] ?? 0);
|
|
if ($user_id) {
|
|
$config['support_user_id'] = $user_id;
|
|
managedConfigSave($config);
|
|
}
|
|
}
|
|
if ($exists) {
|
|
mysqli_query($mysqli, "UPDATE users SET user_name = '$name', user_status = 1, user_archived_at = NULL, user_role_id = $role_id WHERE user_id = $user_id");
|
|
} else {
|
|
$password = mysqli_real_escape_string($mysqli, password_hash(bin2hex(random_bytes(32)), PASSWORD_DEFAULT));
|
|
mysqli_query($mysqli, "INSERT INTO users SET user_name = '$name', user_email = '$email', user_password = '$password', user_auth_method = 'local', user_type = 1, user_status = 1, user_role_id = $role_id");
|
|
$user_id = intval(mysqli_insert_id($mysqli));
|
|
$config['support_user_id'] = $user_id;
|
|
managedConfigSave($config);
|
|
}
|
|
mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = 0 ON DUPLICATE KEY UPDATE user_config_force_mfa = 0");
|
|
|
|
return $user_id;
|
|
}
|
|
|
|
/*
|
|
* The guard on every request of a managed instance - called where each kind of request starts:
|
|
*
|
|
* agent includes/check_login.php, once the agent's session is loaded
|
|
* portal client/includes/check_login.php
|
|
* public includes/load_global_settings.php - which nearly every page loads, so guest links,
|
|
* signing up, joining and password resets are covered without each page asking
|
|
* api api/v1/validate_api_key.php (and public, for an /api/ page loading the settings)
|
|
* login login.php - signing in isn't a change, so only suspended applies
|
|
*
|
|
* Read-only: anything that would change something is turned away - the pages still open, and
|
|
* exports and downloads still work. Suspended: nothing opens but the notice. Signing out
|
|
* always works, and so does a support session from the manager, which has to get in either
|
|
* way - it lasts two hours, and the support user can sign in no other way.
|
|
*/
|
|
function managedRequestGuard(string $surface): void
|
|
{
|
|
if (!managedIsManaged()) {
|
|
// Not managed, or Managed By turned off in config.php - a support session has nothing
|
|
// left to stand on, so it ends here rather than outliving the two hours unchecked
|
|
if (!empty($_SESSION['managed_support']) && session_status() === PHP_SESSION_ACTIVE) {
|
|
session_unset();
|
|
session_destroy();
|
|
redirect('/login.php');
|
|
}
|
|
return;
|
|
}
|
|
|
|
$support_id = managedSupportUserId();
|
|
$agent_id = !empty($_SESSION['logged']) ? intval($_SESSION['user_id'] ?? 0) : 0;
|
|
if (!empty($_SESSION['managed_support'])) {
|
|
if (!$support_id || ($agent_id && $agent_id !== $support_id)
|
|
|| intval($_SESSION['managed_support']['started'] ?? 0) < time() - MANAGED_SUPPORT_SESSION_HOURS * 3600) {
|
|
session_unset();
|
|
session_destroy();
|
|
redirect('/login.php');
|
|
}
|
|
// A support session can't open the credential vault - that takes a user's own password.
|
|
// So nothing that seals the vault's key for someone, or needs a password of its own, is
|
|
// done in one: a password set for an agent, or the key sealed under it, would be sealed
|
|
// empty, and they'd lose the vault
|
|
$sealing = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && (trim((string) ($_POST['password'] ?? '')) !== ''
|
|
|| trim((string) ($_POST['new_password'] ?? '')) !== '' || isset($_POST['ir_reset_user_password'])
|
|
|| isset($_POST['add_api_key']) || isset($_POST['getting_started_team']));
|
|
if ($sealing) {
|
|
flashAlert("A support session can't open this ITFlow's saved passwords, so it can't set or use passwords, or make API keys - invite people instead, or ask one of its admins.", 'error');
|
|
redirect();
|
|
}
|
|
return;
|
|
}
|
|
if ($support_id && $agent_id === $support_id) {
|
|
session_unset();
|
|
session_destroy();
|
|
redirect('/login.php');
|
|
}
|
|
|
|
$mode = managedMode();
|
|
if ($mode === 'active') {
|
|
return;
|
|
}
|
|
|
|
$leaving = isset($_GET['logout']) || isset($_POST['logout']) || isset($_GET['exit_view_as']);
|
|
if ($leaving) {
|
|
return;
|
|
}
|
|
|
|
$state = managedState();
|
|
$message = $state['message'] !== '' ? $state['message'] : ($mode === 'suspended'
|
|
? 'Access has been suspended by ' . managedManagerName() . '.'
|
|
: 'Nothing can be changed for now - ' . managedManagerName() . ' has made this read-only.');
|
|
$signout = !empty($_SESSION['logged']) ? '/agent/post.php?logout' : (!empty($_SESSION['client_logged_in']) ? '/client/post.php?logout' : '');
|
|
|
|
if ($mode === 'suspended') {
|
|
if ($surface === 'api') {
|
|
http_response_code(503);
|
|
header('Content-Type: application/json');
|
|
exit(json_encode(['success' => 'False', 'message' => $message]));
|
|
}
|
|
managedBlockedPage(503, 'Suspended', $message, $state['contact'], $signout, '');
|
|
}
|
|
|
|
// Read-only. Signing in isn't a change
|
|
if ($surface === 'login') {
|
|
return;
|
|
}
|
|
$script = basename((string) ($_SERVER['SCRIPT_NAME'] ?? ''));
|
|
$writing = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' || $script === 'post.php' || $script === 'guest_post.php';
|
|
// Let through: the MFA set-up a user can't get past, taking things out (what read-only is
|
|
// for), and asking the manager straight away whether it's still read-only
|
|
$keys = array_keys($_GET + $_POST);
|
|
$allowed = isset($_POST['enable_mfa']) || isset($_POST['verify_code']) || isset($_POST['portal_mfa_enable'])
|
|
|| preg_grep('/^(export|download)/', $keys) || isset($_POST['managed_checkin_now']);
|
|
if (!$writing || $allowed) {
|
|
return;
|
|
}
|
|
|
|
$ajax = strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'
|
|
|| str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') || $surface === 'api';
|
|
if ($ajax) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json');
|
|
exit(json_encode(['success' => false, 'error' => $message, 'message' => $message]));
|
|
}
|
|
|
|
// Someone signed in hears it on the page they came from; anyone else, here
|
|
if (session_status() === PHP_SESSION_ACTIVE && $signout !== '') {
|
|
flashAlert(escapeHtml($message), 'error');
|
|
redirect();
|
|
}
|
|
$referer = (string) ($_SERVER['HTTP_REFERER'] ?? '');
|
|
$referer_host = (string) parse_url($referer, PHP_URL_HOST) . (parse_url($referer, PHP_URL_PORT) ? ':' . parse_url($referer, PHP_URL_PORT) : '');
|
|
$back = $referer !== '' && $referer_host === ($_SERVER['HTTP_HOST'] ?? '') ? $referer : '/';
|
|
managedBlockedPage(403, 'Read-only', $message, $state['contact'], '', $back);
|
|
}
|
|
|
|
/* The notice in place of a page (includes/managed_blocked.php) - and the end of the request */
|
|
function managedBlockedPage(int $status, string $title, string $message, string $contact, string $signout, string $back): void
|
|
{
|
|
global $mysqli;
|
|
|
|
http_response_code($status);
|
|
$managed_blocked_title = $title;
|
|
$managed_blocked_message = $message;
|
|
$managed_blocked_contact = $contact;
|
|
$managed_blocked_signout = $signout;
|
|
$managed_blocked_back = $back;
|
|
require __DIR__ . '/../includes/managed_blocked.php';
|
|
exit;
|
|
}
|
|
|
|
/*
|
|
* The notice along the top of each page - a support session from the manager, saying whose and
|
|
* until when, or read-only. '' when there's nothing to say.
|
|
*/
|
|
function managedBannerHtml(): string
|
|
{
|
|
if (!managedIsManaged()) {
|
|
return '';
|
|
}
|
|
|
|
$html = '';
|
|
if (!empty($_SESSION['managed_support'])) {
|
|
$support = (array) $_SESSION['managed_support'];
|
|
$who = trim((string) ($support['name'] ?? ''));
|
|
$ends = intval($support['started'] ?? time()) + MANAGED_SUPPORT_SESSION_HOURS * 3600;
|
|
$html .= '<div class="alert alert-info d-flex gap-2 align-items-start" data-managed-banner="support"><i class="fas fa-fw fa-headset mt-1"></i><div><strong>Support session</strong> from '
|
|
. escapeHtml(managedManagerName()) . ($who !== '' ? ' - ' . escapeHtml($who) : '') . '. It ends at ' . date('g:i A', $ends) . '; everything done in it is in the audit log.</div></div>';
|
|
}
|
|
if (managedMode() === 'read_only') {
|
|
$message = managedState()['message'];
|
|
$html .= '<div class="alert alert-warning d-flex gap-2 align-items-start" data-managed-banner="read_only"><i class="fas fa-fw fa-lock mt-1"></i><div><strong>Read-only.</strong> '
|
|
. escapeHtml($message !== '' ? $message : 'Nothing can be changed for now - ' . managedManagerName() . ' has made this read-only.') . '</div></div>';
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/* --------------------------------------------------------------- Manager (this ITFlow) */
|
|
|
|
/*
|
|
* This ITFlow's signing key pair, made the first time it's needed (settings.config_manager_keys).
|
|
* Instances are given the public half when they connect.
|
|
*/
|
|
function managerKeys(): array
|
|
{
|
|
global $mysqli;
|
|
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_manager_keys FROM settings WHERE company_id = 1"));
|
|
$keys = json_decode((string) ($row[0] ?? ''), true);
|
|
if (is_array($keys) && !empty($keys['private']) && !empty($keys['public'])) {
|
|
return $keys;
|
|
}
|
|
|
|
$pair = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
|
|
if (!$pair || !openssl_pkey_export($pair, $private)) {
|
|
throw new RuntimeException("This server's PHP couldn't make a signing key (OpenSSL)");
|
|
}
|
|
$keys = ['private' => $private, 'public' => openssl_pkey_get_details($pair)['key']];
|
|
mysqli_query($mysqli, "UPDATE settings SET config_manager_keys = '" . mysqli_real_escape_string($mysqli, json_encode($keys)) . "' WHERE company_id = 1");
|
|
|
|
return $keys;
|
|
}
|
|
|
|
/* A payload signed with this ITFlow's key - body.signature, both base64url */
|
|
function managerSign(array $payload): string
|
|
{
|
|
$body = managedBase64UrlEncode((string) json_encode($payload));
|
|
openssl_sign($body, $signature, openssl_pkey_get_private(managerKeys()['private']), OPENSSL_ALGO_SHA256);
|
|
return $body . '.' . managedBase64UrlEncode($signature);
|
|
}
|
|
|
|
/* This ITFlow's name and address, as instances see them */
|
|
function managerIdentity(): array
|
|
{
|
|
global $mysqli;
|
|
|
|
$company = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_name, company_email, company_phone, company_phone_country_code FROM companies WHERE company_id = 1")) ?: [];
|
|
$contact = array_filter([(string) ($company['company_email'] ?? ''),
|
|
!empty($company['company_phone']) ? formatPhoneNumber($company['company_phone'], $company['company_phone_country_code'] ?? '') : '']);
|
|
|
|
return ['name' => (string) ($company['company_name'] ?? 'ITFlow'), 'contact' => implode(' · ', $contact)];
|
|
}
|
|
|
|
/* One instance this ITFlow manages, with its client's name - null when there is none */
|
|
function managedInstanceRow(int $instance_id): ?array
|
|
{
|
|
global $mysqli;
|
|
|
|
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT managed_instances.*, client_name FROM managed_instances
|
|
LEFT JOIN clients ON client_id = managed_instance_client_id
|
|
WHERE managed_instance_id = $instance_id LIMIT 1")) ?: null;
|
|
}
|
|
|
|
/* The report an instance last sent, read */
|
|
function managedInstanceReport(array $row): array
|
|
{
|
|
$report = json_decode((string) ($row['managed_instance_report'] ?? ''), true);
|
|
return is_array($report) ? $report : [];
|
|
}
|
|
|
|
/*
|
|
* How an instance is doing, from its last report - state (pending: never connected;
|
|
* waiting: connected, but its cron has never run - a new install whose cron job isn't in
|
|
* yet; offline; online), what needs a look (issues, worst first, each [tone, text]), and
|
|
* what the list shows of it.
|
|
*/
|
|
function managedInstanceHealth(array $row): array
|
|
{
|
|
$report = managedInstanceReport($row);
|
|
$last = $row['managed_instance_last_checkin_at'] ?? null;
|
|
|
|
$cron = (array) ($report['cron'] ?? []);
|
|
if (empty($row['managed_instance_secret_hash'])) {
|
|
$state = 'pending';
|
|
} elseif ($report && empty($cron['last_dispatch_at'])) {
|
|
// It checked in as it connected, and its cron has never run since - a new install whose cron job isn't in yet
|
|
$state = 'waiting';
|
|
} elseif (!$last || strtotime($last) < strtotime('-' . MANAGED_OFFLINE_MINUTES . ' minutes')) {
|
|
$state = 'offline';
|
|
} else {
|
|
$state = 'online';
|
|
}
|
|
|
|
$issues = [];
|
|
if ($state === 'waiting') {
|
|
$issues[] = ['warning', 'Waiting for its cron job - add it in its hosting control panel'];
|
|
} elseif ($state === 'offline') {
|
|
$issues[] = ['danger', $last ? 'Not checked in for ' . str_replace(' ago', '', timeAgo($last)) : 'Connected, but never checked in'];
|
|
}
|
|
if ($report && $state !== 'waiting') {
|
|
if (empty($cron['enabled'])) {
|
|
$issues[] = ['danger', 'Cron is switched off'];
|
|
} elseif (empty($cron['last_dispatch_at']) || strtotime($cron['last_dispatch_at']) < strtotime((string) ($report['reported_at'] ?? 'now')) - 600) {
|
|
$issues[] = ['danger', 'Cron isn\'t running'];
|
|
}
|
|
$app = (array) ($report['app'] ?? []);
|
|
if (!empty($app['db_latest']) && !empty($app['db_version']) && version_compare($app['db_latest'], $app['db_version'], '>')) {
|
|
$issues[] = ['danger', 'Database update not applied'];
|
|
}
|
|
$update = (array) ($report['update'] ?? []);
|
|
if (!empty($update['last']['error'])) {
|
|
$issues[] = ['danger', 'The last update failed'];
|
|
}
|
|
if (!empty($update['available'])) {
|
|
$issues[] = [!empty($update['security']) ? 'danger' : 'info', (!empty($update['security']) ? 'Security update' : 'Update') . ' available' . (!empty($update['release']) ? ' - ' . $update['release'] : '')];
|
|
}
|
|
$backup = (array) ($report['backup'] ?? []);
|
|
if (($backup['last_status'] ?? '') === 'Failed') {
|
|
$issues[] = ['warning', 'The last backup failed'];
|
|
} elseif (empty($backup['last_ok_at'])) {
|
|
$issues[] = ['warning', 'No backups made'];
|
|
} elseif (strtotime($backup['last_ok_at']) < strtotime('-3 days')) {
|
|
$issues[] = ['warning', 'No backup for ' . str_replace(' ago', '', timeAgo($backup['last_ok_at']))];
|
|
}
|
|
if (intval($report['errors_24h'] ?? 0) > 0) {
|
|
$issues[] = ['warning', intval($report['errors_24h']) . ' error' . (intval($report['errors_24h']) === 1 ? '' : 's') . ' logged today'];
|
|
}
|
|
$agents = (array) ($report['agents'] ?? []);
|
|
if (intval($agents['limit'] ?? 0) > 0 && intval($agents['active'] ?? 0) + intval($agents['invites'] ?? 0) >= intval($agents['limit'])) {
|
|
$issues[] = ['info', 'Every seat is taken'];
|
|
}
|
|
}
|
|
|
|
$order = ['danger' => 0, 'warning' => 1, 'info' => 2];
|
|
usort($issues, function ($a, $b) use ($order) {
|
|
return $order[$a[0]] <=> $order[$b[0]];
|
|
});
|
|
|
|
return ['state' => $state, 'issues' => $issues, 'report' => $report];
|
|
}
|
|
|
|
/*
|
|
* A new connection code for an instance - the old one stops working. The code carries this
|
|
* ITFlow's address and a token that works once, for a week; the manager keeps only its hash.
|
|
*/
|
|
function managedInstanceNewCode(int $instance_id): string
|
|
{
|
|
global $mysqli;
|
|
|
|
$token = bin2hex(random_bytes(32));
|
|
$expires = date('Y-m-d H:i:s', strtotime('+' . MANAGED_ENROLL_DAYS . ' days'));
|
|
mysqli_query($mysqli, "UPDATE managed_instances SET managed_instance_enroll_hash = '" . hash('sha256', $token) . "', managed_instance_enroll_expires_at = '$expires'
|
|
WHERE managed_instance_id = $instance_id");
|
|
|
|
return 'ITFLOW-MANAGE:' . managedBase64UrlEncode((string) json_encode(['u' => managedRequestBaseUrl(), 't' => $token]));
|
|
}
|
|
|
|
/* Ask an instance to do something, at its next check-in */
|
|
function managedQueueCommand(int $instance_id, string $command, string $by, string $source = 'manual'): int
|
|
{
|
|
global $mysqli;
|
|
|
|
$command = mysqli_real_escape_string($mysqli, $command);
|
|
$by = mysqli_real_escape_string($mysqli, mb_substr($by, 0, 200));
|
|
$source = mysqli_real_escape_string($mysqli, $source);
|
|
|
|
// The same thing asked twice before the first was picked up is one request
|
|
$queued = mysqli_fetch_row(mysqli_query($mysqli, "SELECT managed_command_id FROM managed_instance_commands
|
|
WHERE managed_command_instance_id = $instance_id AND managed_command = '$command' AND managed_command_status = 'queued' LIMIT 1"));
|
|
if ($queued) {
|
|
return intval($queued[0]);
|
|
}
|
|
|
|
mysqli_query($mysqli, "INSERT INTO managed_instance_commands SET managed_command_instance_id = $instance_id, managed_command = '$command',
|
|
managed_command_source = '$source', managed_command_requested_by = '$by'");
|
|
|
|
return intval(mysqli_insert_id($mysqli));
|
|
}
|
|
|
|
/*
|
|
* Add Instance and Edit Instance's fields, read and checked - [fields, error]. The white-label
|
|
* key is checked here, where it's typed, rather than found wrong on the instance later.
|
|
*/
|
|
function managedInstanceFromInput(array $input): array
|
|
{
|
|
global $mysqli;
|
|
|
|
$fields = [
|
|
'name' => mb_substr(trim((string) ($input['name'] ?? '')), 0, 200),
|
|
'client_id' => max(0, intval($input['client_id'] ?? 0)),
|
|
'notes' => trim((string) ($input['notes'] ?? '')),
|
|
'seat_limit' => min(100000, max(0, intval($input['seat_limit'] ?? 0))),
|
|
'update_policy' => ($input['update_policy'] ?? '') === 'auto' ? 'auto' : 'manual',
|
|
'update_delay_days' => min(90, max(0, intval($input['update_delay_days'] ?? 7))),
|
|
'white_label_key' => preg_replace('/\s+/', '', (string) ($input['white_label_key'] ?? '')),
|
|
'mode' => isset(MANAGED_MODES[$input['mode'] ?? '']) ? $input['mode'] : 'active',
|
|
'mode_message' => mb_substr(trim((string) ($input['mode_message'] ?? '')), 0, 500),
|
|
// Its address, before it connects - its setup command uses it; once connected, it says its own
|
|
'url' => array_key_exists('url', $input) ? rtrim(preg_replace('#^[a-z][a-z0-9+.-]*://#i', '', trim((string) $input['url'])), '/') : null,
|
|
];
|
|
|
|
if ($fields['name'] === '') {
|
|
return [$fields, 'Give it a name.'];
|
|
}
|
|
if ($fields['url'] !== null && $fields['url'] !== '') {
|
|
if (preg_match('/[\s"\'<>]/', $fields['url']) || mb_strlen($fields['url']) > 240) {
|
|
return [$fields, 'Its address is a name like itflow.example.com, without https://.'];
|
|
}
|
|
$fields['url'] = 'https://' . $fields['url'];
|
|
}
|
|
if ($fields['client_id'] && !mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM clients WHERE client_id = " . $fields['client_id']))) {
|
|
return [$fields, 'That client is gone - pick another.'];
|
|
}
|
|
if ($fields['white_label_key'] !== '' && !validateWhitelabelKey($fields['white_label_key'])) {
|
|
return [$fields, "That isn't a white-label key, or it has run out."];
|
|
}
|
|
|
|
return [$fields, ''];
|
|
}
|
|
|
|
/* The SET of a managed_instances write, from managedInstanceFromInput()'s fields */
|
|
function managedInstanceSetSql(array $fields): string
|
|
{
|
|
global $mysqli;
|
|
|
|
$text = function ($value) use ($mysqli) {
|
|
return $value === '' ? 'NULL' : "'" . mysqli_real_escape_string($mysqli, $value) . "'";
|
|
};
|
|
|
|
return "managed_instance_name = '" . mysqli_real_escape_string($mysqli, $fields['name']) . "',
|
|
managed_instance_client_id = " . intval($fields['client_id']) . ",
|
|
managed_instance_notes = " . $text($fields['notes']) . ",
|
|
managed_instance_seat_limit = " . intval($fields['seat_limit']) . ",
|
|
managed_instance_update_policy = '" . $fields['update_policy'] . "',
|
|
managed_instance_update_delay_days = " . intval($fields['update_delay_days']) . ",
|
|
managed_instance_white_label_key = " . $text($fields['white_label_key']) . ",
|
|
managed_instance_mode = '" . $fields['mode'] . "',
|
|
managed_instance_mode_message = " . $text($fields['mode_message'])
|
|
. (($fields['url'] ?? null) !== null ? ", managed_instance_url = " . $text($fields['url']) : '');
|
|
}
|
|
|
|
/*
|
|
* The command that sets up a new install for an instance and connects it, in one go
|
|
* (scripts/setup_cli.php) - with its connection code and what this ITFlow knows: its address,
|
|
* and its client's name, primary location, phone, website, tax id and currency, with its primary
|
|
* contact as the first admin and a password made for them. Kept connected (--manager-required),
|
|
* for an install you host. Run in the new install's folder, as the user that owns its files; it
|
|
* asks for the database and anything else it has to (--ask-missing).
|
|
*/
|
|
function managedSetupCommand(array $row, string $code): string
|
|
{
|
|
global $mysqli;
|
|
|
|
$client_id = intval($row['managed_instance_client_id']);
|
|
$client = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, client_website, client_currency_code, client_tax_id_number FROM clients WHERE client_id = $client_id")) : null;
|
|
$location = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT location_country, location_address, location_city, location_state, location_zip, location_phone_country_code, location_phone
|
|
FROM locations WHERE location_client_id = $client_id AND location_archived_at IS NULL ORDER BY location_primary DESC, location_id ASC LIMIT 1")) : null;
|
|
$contact = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts
|
|
WHERE contact_client_id = $client_id AND contact_archived_at IS NULL AND contact_email <> '' ORDER BY contact_primary DESC, contact_id ASC LIMIT 1")) : null;
|
|
|
|
$options = [
|
|
'base-url' => preg_replace('#^https?://#', '', (string) ($row['managed_instance_url'] ?? '')),
|
|
'company-name' => $client['client_name'] ?? $row['managed_instance_name'],
|
|
'country' => $location['location_country'] ?? '',
|
|
'address' => $location['location_address'] ?? '',
|
|
'city' => $location['location_city'] ?? '',
|
|
'state' => $location['location_state'] ?? '',
|
|
'zip' => $location['location_zip'] ?? '',
|
|
'phone-country-code' => !empty($location['location_phone']) ? ($location['location_phone_country_code'] ?? '') : '',
|
|
'phone' => $location['location_phone'] ?? '',
|
|
'website' => $client['client_website'] ?? '',
|
|
'tax-id' => $client['client_tax_id_number'] ?? '',
|
|
'currency' => $client['client_currency_code'] ?? '',
|
|
'user-name' => $contact['contact_name'] ?? '',
|
|
'user-email' => $contact['contact_email'] ?? '',
|
|
];
|
|
|
|
// Quoted only where a shell would read something into it
|
|
$word = function (string $value) {
|
|
return preg_match('#^[A-Za-z0-9_./=:@%+-]+$#', $value) ? $value : escapeshellarg($value);
|
|
};
|
|
$lines = ['php scripts/setup_cli.php --ask-missing'];
|
|
foreach ($options as $key => $value) {
|
|
if (trim((string) $value) !== '') {
|
|
$lines[] = "--$key=" . $word(trim((string) $value));
|
|
}
|
|
}
|
|
if ($contact) {
|
|
$lines[] = '--generate-password';
|
|
}
|
|
$lines[] = '--manager-code=' . $word($code);
|
|
$lines[] = '--manager-required';
|
|
|
|
return implode(" \\\n ", $lines);
|
|
}
|
|
|
|
/* Words for a command */
|
|
function managedCommandLabel(string $command): string
|
|
{
|
|
return ['update_now' => 'Update ITFlow', 'check_updates' => 'Check for updates'][$command] ?? $command;
|
|
}
|
|
|
|
/* What an instance is told at each check-in - all it needs to set itself */
|
|
function managerStateFor(array $row): array
|
|
{
|
|
$identity = managerIdentity();
|
|
|
|
return [
|
|
'mode' => isset(MANAGED_MODES[$row['managed_instance_mode']]) ? $row['managed_instance_mode'] : 'active',
|
|
'message' => (string) ($row['managed_instance_mode_message'] ?? ''),
|
|
'seat_limit' => intval($row['managed_instance_seat_limit']),
|
|
'white_label_key' => trim((string) ($row['managed_instance_white_label_key'] ?? '')) !== '' ? trim((string) $row['managed_instance_white_label_key']) : null,
|
|
'update_policy' => $row['managed_instance_update_policy'] === 'auto' ? 'auto' : 'manual',
|
|
'update_delay_days' => intval($row['managed_instance_update_delay_days']),
|
|
'manager_name' => $identity['name'],
|
|
'contact' => $identity['contact'],
|
|
];
|
|
}
|
|
|
|
/*
|
|
* An instance connecting with a code (api/managed/enroll.php) - [http status, answer].
|
|
* The code works once; the instance gets its id, a secret to check in with (kept here only
|
|
* as its hash), this ITFlow's name and the public key support sign-ins are signed with.
|
|
*/
|
|
function managerEnroll(array $body, string $ip): array
|
|
{
|
|
global $mysqli;
|
|
|
|
$token = (string) ($body['token'] ?? '');
|
|
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
|
return [400, ['error' => "That isn't a connection code."]];
|
|
}
|
|
$hash = hash('sha256', $token);
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM managed_instances
|
|
WHERE managed_instance_enroll_hash = '$hash' AND managed_instance_archived_at IS NULL LIMIT 1"));
|
|
if (!$row || empty($row['managed_instance_enroll_expires_at']) || strtotime($row['managed_instance_enroll_expires_at']) < time()) {
|
|
return [404, ['error' => "That code isn't valid any more - it works once, for " . MANAGED_ENROLL_DAYS . " days. Make a new one on the manager."]];
|
|
}
|
|
|
|
$instance_id = intval($row['managed_instance_id']);
|
|
$secret = bin2hex(random_bytes(32));
|
|
$url = mysqli_real_escape_string($mysqli, mb_substr(preg_match('#^https?://#', (string) ($body['url'] ?? '')) ? (string) $body['url'] : '', 0, 255));
|
|
$version = mysqli_real_escape_string($mysqli, mb_substr((string) ($body['version'] ?? ''), 0, 20));
|
|
$ip = mysqli_real_escape_string($mysqli, $ip);
|
|
|
|
mysqli_query($mysqli, "UPDATE managed_instances SET managed_instance_secret_hash = '" . hash('sha256', $secret) . "', managed_instance_enrolled_at = NOW(),
|
|
managed_instance_enroll_hash = NULL, managed_instance_enroll_expires_at = NULL, managed_instance_url = '$url', managed_instance_version = '$version',
|
|
managed_instance_last_ip = '$ip', managed_instance_offline_notified_at = NULL
|
|
WHERE managed_instance_id = $instance_id");
|
|
|
|
$name = (string) $row['managed_instance_name'];
|
|
logAudit("Managed Instance", "Connect", escapeSql("$name connected from " . ($url !== '' ? $url : $ip)), intval($row['managed_instance_client_id']), $instance_id);
|
|
appNotify("Managed Instance", escapeSql("$name connected" . ($url !== '' ? " - $url" : '')), "/admin/managed_instance.php?id=$instance_id", intval($row['managed_instance_client_id']), $instance_id);
|
|
|
|
$identity = managerIdentity();
|
|
|
|
return [200, [
|
|
'uuid' => $row['managed_instance_uuid'],
|
|
'secret' => $secret,
|
|
'manager' => ['name' => $identity['name'], 'public_key' => managerKeys()['public']],
|
|
'state' => managerStateFor($row),
|
|
]];
|
|
}
|
|
|
|
/*
|
|
* An instance checking in (api/managed/checkin.php) - [http status, answer]. Stores its
|
|
* report, closes the commands it has answered, decides whether an automatic update is due,
|
|
* and hands back what it should be set to and anything it has been asked.
|
|
*/
|
|
function managerCheckin(string $authorization, array $body, string $ip): array
|
|
{
|
|
global $mysqli;
|
|
|
|
if (trim($authorization) === '' && is_string($body['auth'] ?? null)) {
|
|
$authorization = 'Bearer ' . $body['auth'];
|
|
}
|
|
unset($body['auth']);
|
|
if (!preg_match('/^Bearer ([a-f0-9-]{36}):([a-f0-9]{64})$/', trim($authorization), $match)) {
|
|
return [401, ['error' => 'Not signed']];
|
|
}
|
|
$uuid = mysqli_real_escape_string($mysqli, $match[1]);
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM managed_instances WHERE managed_instance_uuid = '$uuid' LIMIT 1"));
|
|
if (!$row || empty($row['managed_instance_secret_hash']) || !hash_equals((string) $row['managed_instance_secret_hash'], hash('sha256', $match[2]))) {
|
|
return [401, ['error' => "This manager doesn't know this instance - connect it again with a new code"]];
|
|
}
|
|
|
|
$instance_id = intval($row['managed_instance_id']);
|
|
$client_id = intval($row['managed_instance_client_id']);
|
|
$name = (string) $row['managed_instance_name'];
|
|
|
|
if (!empty($row['managed_instance_archived_at'])) {
|
|
return [410, ['released' => true, 'error' => 'Removed from the manager']];
|
|
}
|
|
|
|
// The instance leaving of its own accord
|
|
if (!empty($body['disconnect'])) {
|
|
mysqli_query($mysqli, "UPDATE managed_instances SET managed_instance_secret_hash = NULL WHERE managed_instance_id = $instance_id");
|
|
logAudit("Managed Instance", "Disconnect", escapeSql("$name disconnected itself from management"), $client_id, $instance_id);
|
|
appNotify("Managed Instance", escapeSql("$name disconnected itself from management"), "/admin/managed_instance.php?id=$instance_id", $client_id, $instance_id);
|
|
return [200, ['disconnected' => true]];
|
|
}
|
|
|
|
// The report, kept as sent - checked for shape where it's read
|
|
$acks = (array) ($body['acks'] ?? []);
|
|
unset($body['acks']);
|
|
$report_json = (string) json_encode($body);
|
|
if (strlen($report_json) > 262144) {
|
|
return [413, ['error' => 'Report too large']];
|
|
}
|
|
$app = (array) ($body['app'] ?? []);
|
|
$update = (array) ($body['update'] ?? []);
|
|
$set = [
|
|
"managed_instance_report = '" . mysqli_real_escape_string($mysqli, $report_json) . "'",
|
|
"managed_instance_last_checkin_at = NOW()",
|
|
"managed_instance_last_ip = '" . mysqli_real_escape_string($mysqli, $ip) . "'",
|
|
"managed_instance_version = '" . mysqli_real_escape_string($mysqli, mb_substr((string) ($app['version'] ?? ''), 0, 20)) . "'",
|
|
"managed_instance_db_version = '" . mysqli_real_escape_string($mysqli, mb_substr((string) ($app['db_version'] ?? ''), 0, 20)) . "'",
|
|
"managed_instance_offline_notified_at = NULL",
|
|
];
|
|
if (preg_match('#^https?://#', (string) ($body['url'] ?? ''))) {
|
|
$set[] = "managed_instance_url = '" . mysqli_real_escape_string($mysqli, mb_substr((string) $body['url'], 0, 255)) . "'";
|
|
}
|
|
|
|
// Back from being offline
|
|
if (!empty($row['managed_instance_offline_notified_at'])) {
|
|
appNotify("Managed Instance", escapeSql("$name is checking in again"), "/admin/managed_instance.php?id=$instance_id", $client_id, $instance_id);
|
|
}
|
|
|
|
// What it has been asked - first seen and when, for an automatic update's wait
|
|
$update_key = !empty($update['available']) ? (string) (($update['release'] ?? '') !== '' ? $update['release'] : ($update['latest_commit'] ?? '')) : '';
|
|
if ($update_key !== (string) ($row['managed_instance_update_version'] ?? '')) {
|
|
$set[] = $update_key === '' ? "managed_instance_update_version = NULL, managed_instance_update_seen_at = NULL"
|
|
: "managed_instance_update_version = '" . mysqli_real_escape_string($mysqli, mb_substr($update_key, 0, 40)) . "', managed_instance_update_seen_at = NOW()";
|
|
$row['managed_instance_update_seen_at'] = date('Y-m-d H:i:s');
|
|
}
|
|
mysqli_query($mysqli, "UPDATE managed_instances SET " . implode(', ', $set) . " WHERE managed_instance_id = $instance_id");
|
|
|
|
// The commands it answered
|
|
foreach ($acks as $ack) {
|
|
$command_id = intval($ack['id'] ?? 0);
|
|
if (!$command_id) {
|
|
continue;
|
|
}
|
|
$command = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT managed_command FROM managed_instance_commands
|
|
WHERE managed_command_id = $command_id AND managed_command_instance_id = $instance_id AND managed_command_status = 'sent'"));
|
|
if (!$command) {
|
|
continue;
|
|
}
|
|
$result = mysqli_real_escape_string($mysqli, mb_substr((string) ($ack['message'] ?? ''), 0, 1000));
|
|
// An update queued is still to run - it is done once the report says it has
|
|
$status = empty($ack['ok']) ? 'failed' : ($command['managed_command'] === 'update_now' ? 'running' : 'done');
|
|
mysqli_query($mysqli, "UPDATE managed_instance_commands SET managed_command_status = '$status', managed_command_result = '$result'"
|
|
. ($status !== 'running' ? ", managed_command_done_at = NOW()" : '') . " WHERE managed_command_id = $command_id");
|
|
}
|
|
|
|
// An update that was running and has since finished - as the instance's report says
|
|
$last = (array) ($update['last'] ?? []);
|
|
if (!empty($last['finished_at']) && in_array($last['status'] ?? '', ['Completed', 'Failed'], true)) {
|
|
$running = mysqli_query($mysqli, "SELECT managed_command_id, managed_command_sent_at FROM managed_instance_commands
|
|
WHERE managed_command_instance_id = $instance_id AND managed_command = 'update_now' AND managed_command_status = 'running'");
|
|
while ($command = mysqli_fetch_assoc($running)) {
|
|
// Its clock and ours may differ - a finish within a few minutes of the send counts
|
|
if (strtotime($last['finished_at']) >= strtotime($command['managed_command_sent_at']) - 300) {
|
|
$failed = $last['status'] === 'Failed';
|
|
$result = mysqli_real_escape_string($mysqli, mb_substr($failed ? 'Failed - ' . (string) ($last['error'] ?? '') : 'Updated to ' . (string) ($app['version'] ?? '') . ' (' . (string) ($app['commit'] ?? '') . ')', 0, 1000));
|
|
mysqli_query($mysqli, "UPDATE managed_instance_commands SET managed_command_status = '" . ($failed ? 'failed' : 'done') . "', managed_command_result = '$result', managed_command_done_at = NOW()
|
|
WHERE managed_command_id = " . intval($command['managed_command_id']));
|
|
if ($failed) {
|
|
appNotify("Managed Instance", escapeSql("The update on $name failed"), "/admin/managed_instance.php?id=$instance_id", $client_id, $instance_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// An automatic update - a security release straight away, anything else once it's waited.
|
|
// Not while one is on its way, nor for six hours after one failed, so a broken update isn't
|
|
// tried every minute
|
|
if ($row['managed_instance_update_policy'] === 'auto' && $update_key !== '') {
|
|
$waited = !empty($row['managed_instance_update_seen_at']) && strtotime($row['managed_instance_update_seen_at']) <= strtotime('-' . intval($row['managed_instance_update_delay_days']) . ' days');
|
|
$busy = mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM managed_instance_commands WHERE managed_command_instance_id = $instance_id AND managed_command = 'update_now'
|
|
AND (managed_command_status IN ('queued', 'sent', 'running') OR (managed_command_status = 'failed' AND managed_command_created_at > NOW() - INTERVAL 6 HOUR)) LIMIT 1"));
|
|
if (!$busy && (!empty($update['security']) || $waited)) {
|
|
managedQueueCommand($instance_id, 'update_now', 'Automatic updates', 'auto');
|
|
logAudit("Managed Instance", "Update", escapeSql("Automatic update queued for $name" . (!empty($update['security']) ? ' - a security release' : '')), $client_id, $instance_id);
|
|
}
|
|
}
|
|
|
|
// Anything asked of it, handed over now
|
|
$commands = [];
|
|
$queued = mysqli_query($mysqli, "SELECT managed_command_id, managed_command FROM managed_instance_commands
|
|
WHERE managed_command_instance_id = $instance_id AND managed_command_status = 'queued' ORDER BY managed_command_id ASC");
|
|
while ($command = mysqli_fetch_assoc($queued)) {
|
|
$commands[] = ['id' => intval($command['managed_command_id']), 'command' => $command['managed_command']];
|
|
mysqli_query($mysqli, "UPDATE managed_instance_commands SET managed_command_status = 'sent', managed_command_sent_at = NOW() WHERE managed_command_id = " . intval($command['managed_command_id']));
|
|
}
|
|
|
|
return [200, ['state' => managerStateFor($row), 'commands' => $commands]];
|
|
}
|
|
|
|
/*
|
|
* A link that signs the person asking into an instance as its support user - signed with this
|
|
* ITFlow's key, for that instance only, good for two minutes and once.
|
|
*/
|
|
function managedSupportLink(array $row, string $name, string $email): string
|
|
{
|
|
$token = managerSign([
|
|
'i' => $row['managed_instance_uuid'],
|
|
'n' => $name,
|
|
'e' => $email,
|
|
'x' => time() + MANAGED_SUPPORT_LINK_SECONDS,
|
|
'j' => bin2hex(random_bytes(16)),
|
|
]);
|
|
|
|
return rtrim((string) $row['managed_instance_url'], '/') . '/login_support.php?token=' . rawurlencode($token);
|
|
}
|
|
|
|
/* How many instances have stopped checking in - for the side nav. 0 before the tables exist */
|
|
function managedOfflineCount(): int
|
|
{
|
|
global $mysqli;
|
|
|
|
if (!managerEnabled()) {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM managed_instances WHERE managed_instance_archived_at IS NULL
|
|
AND managed_instance_secret_hash IS NOT NULL AND (managed_instance_last_checkin_at IS NULL OR managed_instance_last_checkin_at < NOW() - INTERVAL " . MANAGED_OFFLINE_MINUTES . " MINUTE)"))[0] ?? 0);
|
|
} catch (Throwable $e) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/* What the list and the page show of a mode */
|
|
function managedModePill(string $mode): string
|
|
{
|
|
$tone = ['active' => 'success', 'read_only' => 'warning', 'suspended' => 'danger'][$mode] ?? 'secondary';
|
|
$icon = ['active' => 'fa-check-circle', 'read_only' => 'fa-lock', 'suspended' => 'fa-ban'][$mode] ?? 'fa-question';
|
|
return '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis border border-' . $tone . '-subtle fw-normal"><i class="fas fa-fw ' . $icon . ' me-1"></i>' . escapeHtml(MANAGED_MODES[$mode] ?? $mode) . '</span>';
|
|
}
|