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: - 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 .= '
Support session from ' . escapeHtml(managedManagerName()) . ($who !== '' ? ' - ' . escapeHtml($who) : '') . '. It ends at ' . date('g:i A', $ends) . '; everything done in it is in the audit log.
'; } if (managedMode() === 'read_only') { $message = managedState()['message']; $html .= '
Read-only. ' . escapeHtml($message !== '' ? $message : 'Nothing can be changed for now - ' . managedManagerName() . ' has made this read-only.') . '
'; } 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 '' . escapeHtml(MANAGED_MODES[$mode] ?? $mode) . ''; }