diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bfb31aa..e4c290b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's three database changes (3.5.4 to 3.5.7) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees job in Maintenance > Cron is set up by itself. +Update from Maintenance > Update — Update ITFlow hands the job to cron, which updates the files and applies this release's four database changes (3.5.4 to 3.5.8) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees, Manager Check-in and Managed Instances jobs in Maintenance > Cron are set up by themselves. ### Security @@ -22,6 +22,7 @@ Update from Maintenance > Update — Queue Update hands the job to cron, which u - API keys: agents are notified in ITFlow a week before an API key expires, so an integration doesn't stop without warning. - Payment Providers: Stripe's fee on a card payment is now recorded as an expense about 15 minutes after the payment, instead of the next morning. A new Payment Provider Fees job in Maintenance > Cron does it, and you can run it more often there. Record fees now still works any time. - Payment Providers: clients can pay the card fee. Turn on Clients pay the card fee on Stripe in Admin > Payment Providers and enter what Stripe charges you, as 2.9% plus 0.30. Each card payment is then made bigger by Stripe's fee on it, so you get the full invoice. The fee goes on the invoice as a Card Fee line, with no tax, when the payment goes through. Clients see it before they pay: on Pay invoices and the checkout page in the client portal, on the page an invoice's link opens, and next to autopay. It applies to payments in the client portal, through an invoice's link, by autopay, and when you charge a saved card, where the Charge Saved Card window shows it too. One payment for several invoices has one fee, shared over them. A client can be let off it with No card fee on their Billing tab. Refunding a card payment from ITFlow takes its Card Fee line back off the invoice. It is off until you turn it on. Card surcharges have rules where you and your clients are - in the US, for one, the card networks cap them and don't allow them on debit cards - so check them before turning it on. +- Managed mode: one ITFlow can now manage others - a client's, a reseller's or your own hosted ones. Add one in the new Admin > Managed Instances, and paste the code it gives you into the other ITFlow's Admin > Managed By. From then on that ITFlow checks in every minute, and you see how each one is doing: its version and whether an update is waiting (a security one stands out), whether cron is running, its last backup, the errors it logged today, and its agents against its seat limit - and you're notified when one stops checking in. From the manager you can update an instance, or let it update itself a set number of days after a release comes out and straight away for a security release; set how many agents it can have and its white-label key; make it read-only, where its people can still sign in, look and export, or suspend it, with a message of your own; and sign in to it as support in a new tab, for two hours, with no password or seat of its own and every sign-in in its audit log. What the manager sets is locked on the instance, which says so - everything else stays the instance's own, and its admins can disconnect it unless its config.php keeps it managed (`$config_manager_required = true`, for a provider hosting it). Instances only ever call the manager, so they can sit behind a firewall, and they send how they're doing - never client data. - Maintenance > Update is easier to follow. It opens with where your install stands - up to date, an update available, an update running, or the database still to finish - and the one button for it, now Update ITFlow instead of Queue Update. Below it is what's new: the release notes for the versions between yours and the latest, read from the changelog each time ITFlow checks for updates, with the developer notes folded away and the commits behind them a click further. Before you update it lists what to do first, and says whether any of ITFlow's own files have been changed on your install, which updating would put back. Once you click Update ITFlow, the page shows the update running, refreshes itself until it's done, and then says when ITFlow was updated, or what stopped it. Alongside are this install's version, branch, database, when it last checked and updated, whether cron is running, and the command to run the same update from a shell, with the folder and user filled in for your server. ### Bug Fixes @@ -29,6 +30,7 @@ Update from Maintenance > Update — Queue Update hands the job to cron, which u - Transactions: the list, its Transfers pill, Reconcile and the transactions export load faster again. On a test with eight years of books, the Transfers pill went from about a third of a second in 26.11.2 to under a fifth. - Expenses: billable expenses waiting to go on an invoice slowed down every page, because the side menu counts them and each one was checked against every expense. With a few hundred waiting, that was about a third of a second on every page. The invoice page, Add Expenses and an expense's quick look had the same delay. All of them are quick again. - Transactions: on All accounts, Money in and Money out counted every transfer between your own accounts twice - once out of one account and once into the other - so both were far too high. A Stripe payout to the bank, a check deposit or paying off a card all added to both. They now leave transfers between your accounts out, as the dashboard and Income already did. The Transfers pill still lists them, and one account on its own still counts them as money in and out of that account. The Difference was always right. +- App Logs: the email parser logged an error every minute while email-to-ticket parsing was switched off, burying the errors that matter. It now just stops, and Maintenance > Cron says why. - Phone numbers: outside North America, a number shown with its country code kept the 0 - or the 8 in Russia - that is only dialled within the country, as "+49 0172 1234567" for a German mobile, wherever it showed: ITFlow's pages, the client portal (a contact's own profile, and the Contact us details), emails, PDFs and exports. Click-to-call dialled it that way too, and so failed. Numbers are now written the way each country writes them: "+49 172 1234567", and "0172 1234567" without the country code. This covers every country, not only the ones ITFlow had its own rules for. A German landline such as Munich's is split after its area code ("+49 89 1234567"), Italy keeps the 0 its landlines are dialled with from abroad ("+39 06 1234 5678"), and South African numbers read "+27 82 123 4567". A number typed with or without that leading 0, or with its country code at the front, shows and dials the same. North American numbers look as they did. ### Developer Updates @@ -54,6 +56,14 @@ Update from Maintenance > Update — Queue Update hands the job to cron, which u - New `phoneDialNumber($number, $country_code)` gives `+`, the code and the number without its national prefix for `tel:` links (just the digits without a code). `contactPhoneLink()` and `vendorDialNumber()` use it, as do the links that built `+code` and the stored digits themselves - the contact page and window, the client overview and list, the location page, the ticket and its quick look, the portal's home, contact and ticket pages, and the guest ticket page. The ticket quick look's link no longer runs the extension into the number; it dials it after a pause, as the contact page does. - Database 3.5.7 adds `settings.config_update_details` (MEDIUMTEXT, JSON), written by `cron/update_check.php` through the new `updateCheckDetails()` in `functions/app.php`: `APP_VERSION` from `git show origin/:includes/app_version.php`, the `CHANGELOG.md` sections newer than the installed `APP_VERSION` (and the installed version's own section when commits are pending but the version hasn't moved, as on develop) from `updateChangelogSections()` - at most twelve, about 200 KB - and `git status --porcelain --untracked-files=no`, the first 50 paths and the count. It's written with `mysqli_real_escape_string()` rather than `escapeSql()`, which strips anything tag-shaped. Like the other update columns, `admin/update.php` and the job check for it first. - `admin/update.php` is rewritten around one state (updating, checking, database, available, manual for a non-git install, unknown, current), with the existing reads kept and each new one guarded by `settingsColumnExists()`. `updateChangelogHtml()` renders the small part of Markdown `CHANGELOG.md` uses - `###` headings, `-` lists with indented continuation lines, paragraphs, `` `code` ``, `**bold**` and http(s) links - escaping everything first, and folds a Developer heading into `
`. A job left at Running past 10 minutes (the check) or 30 (the update) isn't treated as still going. The updating and checking states reload through `js/auto_reload.js`. `cron/app_update.php` sets `cron_job_run_now` on `update_check` after a successful update, so the page doesn't go on offering what was just installed. +- Database 3.5.8 adds `managed_instances` (one row per instance a manager manages: what it sets - `_mode`, `_mode_message`, `_seat_limit`, `_white_label_key`, `_update_policy`, `_update_delay_days` - and what the instance last reported, `_report` JSON), `managed_instance_commands` (`update_now` and `check_updates`, queued → sent → running → done, failed or cancelled) and `settings.config_manager_keys` (TEXT, the manager's EC P-256 key pair as JSON) and `settings.config_managed` (MEDIUMTEXT, the instance's manager, id, secret, the state it was last given and the answers to send). Both sides live in the new `functions/managed.php`. +- Protocol: the instance does all the calling, with `managedHttpJson()` (curl, JSON POST, redirects followed with the POST kept). A connection code is `ITFLOW-MANAGE:` and base64url JSON `{"u": manager address, "t": 64 hex}`; `api/managed/enroll.php` (`managerEnroll()`) takes the token once, within 7 days, and answers the instance's uuid, a 64-hex secret (the manager keeps only its SHA-256), the manager's name and public key, and the state. `api/managed/checkin.php` (`managerCheckin()`) is called every minute by the new `cron/manager_checkin.php` (first in `includes/cron_jobs.php`) with `managedReport()` and the answers to last time's commands; the secret goes as `Authorization: Bearer uuid:secret` and again as `auth` in the body, as some servers drop the header before PHP. It answers the state and any queued commands (marked sent), 401 for an unknown instance, and 410 `{"released": true}` for a removed one, on which the instance disconnects itself. Report times are ISO 8601 with their offset (`managedIsoTime()`), so a manager in another time zone reads them right. +- Updates: `managedRunCommand('update_now')` queues the same update as Maintenance > Update (`config_update_queued_at` and `cron_job_run_now` on `app_update`), and the command stays running until a report's `update.last` shows `app_update` finished after it was sent. `managerCheckin()` queues an automatic `update_now` when `_update_policy` is auto and the update has been seen `_update_delay_days` days (`_update_version`, `_update_seen_at`), or straight away when the changelog between the versions has a `### Security` section - but not while one is pending or for six hours after one failed. The new `cron/managed_instances.php` (every 5 minutes) notifies once when an instance hasn't checked in for 10 minutes (`_offline_notified_at`, cleared by its next check-in) and fails commands sent an hour ago with no answer, or updates running for two hours. +- Support sign-in: `managedSupportLink()` signs `{i: uuid, n, e, x: expiry, j: nonce}` with the manager's private key (`managerSign()`, OpenSSL, SHA-256); the new `login_support.php` checks it with the public key the instance was given (`managedVerifySupportToken()`): this instance, two minutes with a minute's grace for clocks, the nonce not used before. It signs in as `managedSupportUser()` - one admin user per manager, `managed-support@.invalid`, a random password, `user_config_force_mfa` 0 - with `$_SESSION['managed_support']`, and logs and notifies who it was. The support user only gets in that way, for two hours, and is archived on disconnect. Its session can't open the credential vault, so the guard turns away a POST in one that would seal the vault's key for someone or needs a password - a non-empty `password` or `new_password`, `ir_reset_user_password`, `add_api_key`, `getting_started_team` - rather than seal it empty. `encryptUserSpecificKey()`, `decryptCredentialEntry()` and `encryptCredentialEntry()` read a missing session vault key as empty instead of warning. +- `managedRequestGuard($surface)` enforces the mode: `agent` in `includes/check_login.php`, `portal` in `client/includes/check_login.php`, `api` in `api/v1/validate_api_key.php`, `login` in `login.php` and `client/login_microsoft.php` (suspended only), and `public` at the end of `includes/load_global_settings.php`, which nearly every guest and pre-sign-in page loads (guarded by `function_exists()`, as some portal pages load it before `functions.php`). Read-only refuses any POST and any request to a `post.php` or `guest_post.php` - but not signing out, MFA set-up, a key starting `export` or `download`, or `managed_checkin_now` - with a flash, a 403 JSON for ajax and the API, or `includes/managed_blocked.php` without a session; suspended shows that page (503), or 503 JSON for the API. `managedBannerHtml()` (from `includes/inc_alert_feedback.php` and the portal header) shows read-only and support sessions. +- Cron: `cronJobClaim($mysqli, $job, $paused)` doesn't claim a paused job - a due one gets `Stopped: Paused - ...` and stays due, Run Now included. `cron/cron.php` pauses every job but `MANAGED_KEEP_JOBS` while `managedMode()` isn't active, asking again for each job so a check-in at the top of the cycle applies to the rest of it. +- Locks: `getAgentUserLimit()` returns `managedSeatLimit()` when the manager sets one, and `getActiveAgentUserCount()` leaves out `managedSupportUserId()`. `managedApplyState()` writes the manager's white-label key to `config_whitelabel_key` and takes it out again when the manager stops setting it, and Settings > Modules doesn't save over it. `admin/update.php` hides Update ITFlow and `queue_update` refuses on a managed instance. `managedRequired()` reads `$config_manager_required`. +- `cron/ticket_email_parser.php` no longer writes an App Logs error when cron or parsing is switched off - `cronJobStop()` already records why, and with parsing off it was an error a minute, which `managedReport()` counts. The audit log knows the Managed Instance and Management types and the Connect, Disconnect, Remove, Release, Code, Mode and Support actions. ## [26.11.2] diff --git a/admin/database_updates/3.5.8.php b/admin/database_updates/3.5.8.php new file mode 100644 index 000000000..136cf7748 --- /dev/null +++ b/admin/database_updates/3.5.8.php @@ -0,0 +1,80 @@ + "TEXT DEFAULT NULL AFTER `config_update_details`", + 'config_managed' => "MEDIUMTEXT DEFAULT NULL AFTER `config_manager_keys`", + ] as $itflow_column => $itflow_definition) { + $itflow_column_exists = mysqli_query($mysqli, "SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'settings' AND COLUMN_NAME = '$itflow_column' LIMIT 1"); + if (!$itflow_column_exists || mysqli_num_rows($itflow_column_exists) === 0) { + mysqli_query($mysqli, "ALTER TABLE `settings` ADD `$itflow_column` $itflow_definition"); + } + } + + unset($itflow_column, $itflow_definition, $itflow_column_exists); diff --git a/admin/includes/side_nav.php b/admin/includes/side_nav.php index 5ac938662..5a626a236 100644 --- a/admin/includes/side_nav.php +++ b/admin/includes/side_nav.php @@ -180,6 +180,28 @@ + + + + + +
  • ' . $label . '' . $value_html . '
  • '; +}; +$lock = ''; + +?> + + + +
    +
    + +
    +
    +
    + +
    +

    Not managed

    +

    + Another ITFlow can manage this one - usually the MSP or provider that looks after it. To connect, paste the code + from its Admin > Managed Instances page. +

    +
    +
    + + +
    Cron isn't running here, and this ITFlow checks in with its manager from cron. Connect if you like, but set up cron too, or the manager sees it as offline.
    + + +
    + +
    + +
    + + +
    +
    It works once, for a week. If it has run out, ask for a new one.
    +
    +
    + +
    + + +
    +
    As people reach it in a browser - the manager's support sign-ins come here. Nothing else connects to it: it does the calling.
    +
    + +
    +
    +
    + +
    + +
    +
    +
    +

    What a manager can do

    +
    +
      +
    • See how it's doing - versions, updates, cron, backups, errors and seats. No client data leaves here.
    • +
    • Update it, by hand or automatically - and then updates come only from there.
    • +
    • Set how many agents it can have, and its white-label key.
    • +
    • Sign in as support, for two hours at a time - each time in the audit log, and you're notified.
    • +
    • Make it read-only, or suspend it.
    • +
    • Everything else stays yours, and you can disconnect it.
    • +
    +
    +
    +
    + + + +
    +
    + +
    +
    +
    + +
    +

    Managed by + +

    +
    + + +
    + + +

    + The last check-in didn't get through: +

    +

    The last one that did was .

    + +

    Checked in . It checks in every minute from cron.

    + +

    Connected - it checks in every minute from cron.

    + + +

    Cron isn't running, so this ITFlow isn't checking in - sees it as offline. See Maintenance > Cron.

    + + +
    +
    + + +
    + +
    + + +
    + +
    + +

    This install's config.php keeps it managed ($config_manager_required), so it can't be disconnected here.

    + +
    +
    +
    +
    + + +
    +
    +

    What it tells

    +
    +
    +

    Each check-in sends how this ITFlow is doing, and nothing about your clients beyond how many there are:

    +
      +
    • its address, company name and time zone
    • +
    • its version, database version, PHP version and branch, and whether an update is waiting
    • +
    • how the last update went, whether cron is running, and when the last backup was
    • +
    • how many errors were logged today, how many agents and clients it has, and its storage
    • +
    +

    No tickets, documents, credentials, invoices or contacts.

    +
    +
    + +
    + +
    + + +
    +
    +

    Set by

    +
    +
      + ' . escapeHtml($state['message']) . '
    ' : '')) ?> + ' . $state['seat_limit'] . ' agent' . ($state['seat_limit'] === 1 ? '' : 's') . '' . $lock . '
    ' . getAgentSeatsTaken() . ' taken
    ' + : 'Not set from there') ?> + Set from there' . $lock : 'Yours to set Modules') ?> + ' . ($state['update_policy'] === 'auto' ? 'Automatic' : 'From there') . '' . $lock + . ($state['update_policy'] === 'auto' ? '
    ' . ($state['update_delay_days'] > 0 ? $state['update_delay_days'] . ' day' . ($state['update_delay_days'] === 1 ? '' : 's') . ' after a release' : 'As soon as a release is out') . ', security fixes straight away
    ' : '')) ?> + ' . escapeHtml(timeAgo($last_support['log_created_at'])) . '' + : 'Not yet') ?> + + +
    + +
    + + + + + Managed Instances > one instance. At the top, how it is - waiting to connect, + * checking in or gone quiet - with what can be done to it: sign in as support, update it, + * check for updates, make it read-only or suspend it. Under that, what needs a look from its + * last report, the connection code while it's waiting for one, and what it has been asked and + * how that went; alongside, what this manager sets for it and what it reported about itself. + * + * A connection code shows once, on the page its making redirects to - only its hash is kept + * (managedInstanceNewCode()). The handlers are admin/post/managed_instances.php. + */ + +require_once "includes/inc_all_admin.php"; + +if (!settingsColumnExists($mysqli, 'config_manager_keys')) { + redirect('managed_instances.php'); +} + +$instance_id = intval($_GET['id'] ?? 0); +$row = managedInstanceRow($instance_id); + +if (!$row) { ?> +
    That instance is gone - it may have been deleted. Managed Instances
    + ['tone' => 'secondary', 'icon' => 'fa-unlink'], + $state === 'pending' => ['tone' => 'secondary', 'icon' => 'fa-plug'], + $state === 'offline' => ['tone' => 'danger', 'icon' => 'fa-server'], + $worst === 'danger' => ['tone' => 'danger', 'icon' => 'fa-server'], + $worst === 'warning' => ['tone' => 'warning', 'icon' => 'fa-server'], + default => ['tone' => 'success', 'icon' => 'fa-server'], +}; + +// What it has been asked, newest first +$commands = []; +$sql = mysqli_query($mysqli, "SELECT * FROM managed_instance_commands WHERE managed_command_instance_id = $instance_id + ORDER BY managed_command_created_at DESC, managed_command_id DESC LIMIT 15"); +while ($command = mysqli_fetch_assoc($sql)) { + $commands[] = $command; +} +$waiting = (bool) array_filter($commands, function ($command) { + return in_array($command['managed_command_status'], ['queued', 'sent', 'running'], true); +}); +$updating = (bool) array_filter($commands, function ($command) { + return $command['managed_command'] === 'update_now' && in_array($command['managed_command_status'], ['queued', 'sent', 'running'], true); +}); + +$can_act = !$removed && $connected && $url !== ''; + +$status_pill = function (string $status) { + $tone = ['queued' => 'secondary', 'sent' => 'info', 'running' => 'primary', 'done' => 'success', 'failed' => 'danger', 'cancelled' => 'secondary'][$status] ?? 'secondary'; + $words = ['queued' => 'Waiting for its check-in', 'sent' => 'Sent', 'running' => 'Running', 'done' => 'Done', 'failed' => 'Failed', 'cancelled' => 'Cancelled'][$status] ?? $status; + return '' . escapeHtml($words) . ''; +}; + +$fact = function (string $label, string $value_html) { + return '
  • ' . $label . '' . $value_html . '
  • '; +}; +$when = function ($datetime, string $never = 'Never') { + return $datetime ? '' . escapeHtml(timeAgo($datetime)) . '' : '' . $never . ''; +}; +$size = function ($mb) { + $mb = floatval($mb); + return $mb >= 1024 ? round($mb / 1024, 1) . ' GB' : round($mb) . ' MB'; +}; + +?> + + + +
    + +
    + + +
    +
    > +
    + +
    +

    + + Removed + + + +

    +
    + + + + + + + + + +
    + +

    + + Removed . It lets go at its next check-in - what this manager set comes off it, and its support user is archived. + + + + . + It checks in every minute from its own cron - either cron has stopped there, the server is down, or it can't reach this ITFlow. + + Checked in . + + +

    + + +

    Updating - it picks the request up at its next check-in and updates in the background. This page refreshes itself.

    + +

    It updates itself + .

    + + + +
    + + Sign in as support + + + Update + + + + + +
    + + + +
    +
    +
    +
    + + + +
    +
    +

    Connect it

    +
    +
    +
      +
    1. On that ITFlow, sign in as an admin and open Admin > Managed By. It needs to be on this version or newer.
    2. +
    3. Paste the code there and choose Connect. It works once, until .
    4. +
    + +
    +
    + +
    +

    Copy it now - it isn't shown again. If it's lost, make a new one.

    + time()) { ?> +

    The code was shown when it was made - only a fingerprint of it is kept here. If it's lost, make a new one; the old one stops working.

    + New connection code + +

    There's no code that works - .

    + Make a connection code + +

    + It checks in with this ITFlow at , so that has to be reachable from where it is. + Nothing here ever connects to it - it can be behind a firewall. +

    +
    +
    + + + + +
    +
    +

    Health

    +
    Reported
    +
    + +
    All good - cron is running, it's up to date, backups are recent and nothing has gone wrong today.
    + +
      + +
    • + + +
    • + +
    + + + + +
    + + + +
    +
    +

    Requests

    +
    + +
    Nothing asked of it yet. Updates you start here, and automatic ones, show here with how they went.
    + +
    + + + + + + + + + + + + + + + + + + + +
    RequestStatusAskedResult
    +
    +
    Automatic' : escapeHtml((string) $command['managed_command_requested_by']) ?>
    +
    +
    +
    + +
    + +
    + +
    + + +
    +
    +

    Set from here

    + +
    + +
    +
      + ' . escapeHtml($row['managed_instance_mode_message']) . '
    ' : '')) ?> + 0 ? '' . $seat_limit . ' agent' . ($seat_limit === 1 ? '' : 's') . '' : 'None - its own config decides') ?> + + Not set from here' + : ($key_info ? 'On
    until ' . escapeHtml(date('M j, Y', strtotime((string) $key_info['expires']))) . '
    ' : 'The key has run out')) ?> + Automatic
    ' . (intval($row['managed_instance_update_delay_days']) > 0 ? intval($row['managed_instance_update_delay_days']) . ' day' . (intval($row['managed_instance_update_delay_days']) === 1 ? '' : 's') . ' after one comes out' : 'As soon as one comes out') . ' - security fixes straight away
    ' + : 'By hand
    From here - its admins can\'t
    ') ?> + +
    + + +
    +
    +

    The instance

    +
    + +
    Its details show once it has checked in.
    + +
      + ' . escapeHtml((string) ($app['version'] ?? '')) . '' + . (!empty($update['available']) ? ' → ' . escapeHtml(!empty($update['release']) ? $update['release'] : intval($update['commits'] ?? 0) . ' new') . '' : '') + . (!empty($app['branch']) ? '
      ' . escapeHtml($app['branch']) . (!empty($app['commit']) ? ' · ' . escapeHtml($app['commit']) . '' : '') . '
      ' : '')) ?> + ') ? ' → ' . escapeHtml($app['db_latest']) . '' : '')) ?> + + ' . (intval($agents['active'] ?? 0) + intval($agents['invites'] ?? 0)) . '' . (intval($agents['limit'] ?? 0) > 0 ? ' of ' . intval($agents['limit']) : '') + . (intval($agents['invites'] ?? 0) > 0 ? '
      ' . intval($agents['invites']) . ' invited
      ' : '') : '—') ?> + + + Switched off' : 'Last ran ' . $when($cron['last_dispatch_at'] ?? null)) ?> + 0 ? '' . intval($report['errors_24h']) . '' : '0') ?> + + ' . $size($disk['db_mb'] ?? 0) . ' database
    ') ?> + + + + ' . escapeHtml((string) $row['managed_instance_last_ip']) . '') ?> + + + +
    + + +
    +
    +

    Notes

    +
    +
    +
    + + + + + + + + + + + Managed Instances - the ITFlow installs this one manages (functions/managed.php). + * Each row says what it is and whose, the version it runs and whether an update is waiting, + * how it's doing (from the report it sends every minute), its agents against the seat limit + * set here, whether it's active, read-only or suspended, and when it last checked in. Removed + * instances sit under Removed, where they can be deleted. Add Instance makes one and its + * connection code; the handlers are admin/post/managed_instances.php. + */ + +// Default Column Sortby Filter +$sort = "managed_instance_name"; +$order = "ASC"; + +require_once "includes/inc_all_admin.php"; + +// The manager's tables arrive with a migration - before it, say so rather than throw +if (!settingsColumnExists($mysqli, 'config_manager_keys')) { ?> +
    Managing other ITFlow installs needs this install's database update - apply it from Maintenance > Update first.
    + "managed_instance_name $order", + 'managed_instance_version' => "managed_instance_version IS NULL, INET_ATON(CONCAT(managed_instance_version, '.0')) $order, managed_instance_version $order, managed_instance_name ASC", + 'managed_instance_last_checkin_at' => "managed_instance_last_checkin_at IS NULL, managed_instance_last_checkin_at $order, managed_instance_name ASC", + 'client_name' => "client_name IS NULL, client_name $order, managed_instance_name ASC", +]; +if (!isset($sortable_columns[$sort])) { + $sort = 'managed_instance_name'; +} + +$search = "(managed_instance_name LIKE '%$q%' OR managed_instance_url LIKE '%$q%' OR client_name LIKE '%$q%' OR managed_instance_version LIKE '%$q%')"; + +$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS managed_instances.*, client_name + FROM managed_instances + LEFT JOIN clients ON client_id = managed_instance_client_id + WHERE $search AND managed_instance_archived_at IS " . ($show_removed ? 'NOT NULL' : 'NULL') . " + ORDER BY {$sortable_columns[$sort]}, managed_instance_id ASC + LIMIT $record_from, $record_to"); + +$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); + +$counts = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(managed_instance_archived_at IS NULL), 0), COALESCE(SUM(managed_instance_archived_at IS NOT NULL), 0) + FROM managed_instances LEFT JOIN clients ON client_id = managed_instance_client_id WHERE $search")); + +$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) { + return '' + . $label . ($sort === $column ? " $order_icon" : '') . ''; +}; + +$csrf = $_SESSION['csrf_token']; + +?> + +
    +
    +

    Managed Instances

    +
    + +
    +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + +
    + +
    + + "> + + + + + + + + + + + 'success', 'offline' => 'danger', 'pending' => 'secondary'][$health['state']]; + $last = $row['managed_instance_last_checkin_at']; + $last_html = $health['state'] === 'pending' + ? 'Not connected yet' + : ($last ? '' . escapeHtml(timeAgo($last)) . '' : 'Never'); + $page = "managed_instance.php?id=$instance_id"; + ?> + + + + + + + + + + +
    HealthAgents
    +
    + +
    + + +
    + +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    + +
    + +
    + + — + +
    + + — + + All good + + +
    + + 2) { ?>
    and more
    + +
    + + 0 ? ' of ' . $seat_limit : '' ?> +
    0 ? 'seats' : 'agents, no limit' ?>
    + + — + +
    + +
    +
    + 'managed instances', + 'icon' => 'fa-network-wired', + 'filtered' => $q !== '', + 'archived' => $show_removed, + 'archived_title' => 'No removed instances', + 'views' => ['show'], + 'text' => 'Manage other ITFlow installs from here - a client\'s, a reseller\'s or your own hosted ones. Each checks in every minute with how it\'s doing; from here you set its seat limit, white label and updates, update it, make it read-only or suspend it, and sign in to it as support.', + 'actions' => [['label' => 'Add Instance', 'modal' => 'modals/managed_instance/managed_instance_form.php']], + ]; + require_once "../includes/filter_footer.php"; + ?> +
    + + Managed Instances) - one window for both: what it is + * and whose, then what this manager sets for it - seat limit and updates, white label, and + * whether its people can use it. ?id= edits; without one it's new. Posts add_managed_instance + * or edit_managed_instance (admin/post/managed_instances.php). + */ + +require_once '../../includes/modal_header.php'; + +enforceAdminPermission(); + +$instance = isset($_GET['id']) ? managedInstanceRow(intval($_GET['id'])) : null; +$is_new = $instance === null; + +ob_start(); + +if (!$is_new && !empty($instance['managed_instance_archived_at'])) { ?> + + + + + + 0, + 'managed_instance_name' => '', + 'managed_instance_client_id' => 0, + 'managed_instance_notes' => '', + 'managed_instance_seat_limit' => 0, + 'managed_instance_update_policy' => 'manual', + 'managed_instance_update_delay_days' => 7, + 'managed_instance_white_label_key' => '', + 'managed_instance_mode' => 'active', + 'managed_instance_mode_message' => '', + ]; + $form_id = 'managedInstanceForm' . intval($form['managed_instance_id']); + $value = function ($key) use ($form) { + return escapeHtml((string) ($form[$key] ?? '')); + }; + $client_id = intval($form['managed_instance_client_id']); + ?> + + +
    + + + + + + + +
    + + Managed By) - connecting this ITFlow to a manager with its code, + * checking in straight away, and disconnecting. The workings are in functions/managed.php. + */ + +defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); + +if (isset($_POST['managed_connect'])) { + + validateCSRFToken(); + + if (!settingsColumnExists($mysqli, 'config_managed')) { + flashAlert("Apply the database update first", 'error'); + redirect(); + } + if (managedIsManaged()) { + flashAlert("This ITFlow is already managed by " . escapeHtml(managedManagerName()) . " - disconnect it first", 'error'); + redirect(); + } + + $url = rtrim(trim((string) ($_POST['url'] ?? '')), '/'); + if (!preg_match('#^https?://[^\s/]+(/[^\s]*)?$#', $url)) { + flashAlert("That isn't an address - it starts https://", 'error'); + redirect(); + } + + $error = ''; + if (!managedEnroll((string) ($_POST['code'] ?? ''), $url, $error)) { + flashAlert("Not connected - " . escapeHtml($error), 'error'); + redirect(); + } + + $manager = managedManagerName(); + logAudit("Management", "Connect", "$session_name connected this ITFlow to " . escapeSql($manager) . " to be managed"); + + flashAlert("Connected - " . escapeHtml($manager) . " manages this ITFlow now"); + + redirect(); + +} + +if (isset($_POST['managed_checkin_now'])) { + + validateCSRFToken(); + + if (!managedIsManaged()) { + flashAlert("This ITFlow isn't managed", 'error'); + redirect(); + } + + $result = managedCheckin(); + if (!$result['ok']) { + flashAlert("The check-in didn't get through - " . escapeHtml($result['error']), 'error'); + } elseif (!managedIsManaged()) { + flashAlert("Checked in - the manager has removed this ITFlow, so it is no longer managed", 'info'); + } else { + flashAlert("Checked in with " . escapeHtml(managedManagerName()) . "" . ($result['commands'] ? ' - it had ' . $result['commands'] . ' request' . ($result['commands'] === 1 ? '' : 's') : '')); + } + + redirect(); + +} + +if (isset($_POST['managed_disconnect'])) { + + validateCSRFToken(); + + if (!managedIsManaged()) { + flashAlert("This ITFlow isn't managed", 'error'); + redirect(); + } + if (managedRequired()) { + flashAlert("This install's config.php keeps it managed, so it can't be disconnected here", 'error'); + redirect(); + } + + $manager = managedManagerName(); + managedDisconnect('disconnected'); + + logAudit("Management", "Disconnect", "$session_name disconnected this ITFlow from " . escapeSql($manager)); + + flashAlert("Disconnected - " . escapeHtml($manager) . " no longer manages this ITFlow", 'error'); + + redirect(); + +} diff --git a/admin/post/managed_instances.php b/admin/post/managed_instances.php new file mode 100644 index 000000000..7c958e70f --- /dev/null +++ b/admin/post/managed_instances.php @@ -0,0 +1,229 @@ + Managed Instances) - adding and changing them, their connection + * codes, asking them to update, read-only and suspended, signing in to them as support, and + * removing them. The workings are in functions/managed.php. + */ + +defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); + +/* The instance a request is about, not removed - or back with why */ +$managed_instance_for_request = function ($id, bool $connected = false) { + $row = managedInstanceRow(intval($id)); + if (!$row) { + flashAlert("That instance is gone - it may have been deleted", 'error'); + redirect('managed_instances.php'); + } + if (!empty($row['managed_instance_archived_at'])) { + flashAlert(escapeHtml($row['managed_instance_name']) . " has been removed", 'error'); + redirect("managed_instance.php?id=" . intval($row['managed_instance_id'])); + } + if ($connected && (empty($row['managed_instance_secret_hash']) || trim((string) $row['managed_instance_url']) === '')) { + flashAlert(escapeHtml($row['managed_instance_name']) . " isn't connected yet", 'error'); + redirect("managed_instance.php?id=" . intval($row['managed_instance_id'])); + } + return $row; +}; + +if (isset($_POST['add_managed_instance'])) { + + validateCSRFToken(); + + [$fields, $error] = managedInstanceFromInput($_POST); + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + + // The signing key is made now, so a server that can't make one says so before anything connects + try { + managerKeys(); + } catch (Throwable $e) { + flashAlert(escapeHtml($e->getMessage()), 'error'); + redirect(); + } + + mysqli_query($mysqli, "INSERT INTO managed_instances SET managed_instance_uuid = '" . managedUuid() . "', " . managedInstanceSetSql($fields)); + $instance_id = intval(mysqli_insert_id($mysqli)); + + $_SESSION['managed_instance_code'][$instance_id] = managedInstanceNewCode($instance_id); + + logAudit("Managed Instance", "Create", "$session_name added managed instance " . escapeSql($fields['name']), $fields['client_id'], $instance_id); + + flashAlert("" . escapeHtml($fields['name']) . " added - now connect it with the code below"); + + redirect("managed_instance.php?id=$instance_id"); + +} + +if (isset($_POST['edit_managed_instance'])) { + + validateCSRFToken(); + + $row = $managed_instance_for_request($_POST['managed_instance_id'] ?? 0); + $instance_id = intval($row['managed_instance_id']); + + [$fields, $error] = managedInstanceFromInput($_POST); + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + + mysqli_query($mysqli, "UPDATE managed_instances SET " . managedInstanceSetSql($fields) . " WHERE managed_instance_id = $instance_id"); + + // What changed, for the audit log - what it's set to, in words + $changes = []; + if ($row['managed_instance_name'] !== $fields['name']) { + $changes[] = 'renamed from ' . $row['managed_instance_name']; + } + if (intval($row['managed_instance_seat_limit']) !== $fields['seat_limit']) { + $changes[] = 'seat limit ' . ($fields['seat_limit'] ?: 'none') . ' (was ' . (intval($row['managed_instance_seat_limit']) ?: 'none') . ')'; + } + if ($row['managed_instance_update_policy'] !== $fields['update_policy'] || intval($row['managed_instance_update_delay_days']) !== $fields['update_delay_days']) { + $changes[] = 'updates ' . ($fields['update_policy'] === 'auto' ? 'automatic after ' . $fields['update_delay_days'] . ' days' : 'by hand'); + } + if ((string) $row['managed_instance_white_label_key'] !== $fields['white_label_key']) { + $changes[] = $fields['white_label_key'] === '' ? 'white-label key taken off' : 'white-label key set'; + } + if ($row['managed_instance_mode'] !== $fields['mode']) { + $changes[] = strtolower(MANAGED_MODES[$fields['mode']]) . ' (was ' . strtolower(MANAGED_MODES[$row['managed_instance_mode']] ?? $row['managed_instance_mode']) . ')'; + } + if (intval($row['managed_instance_client_id']) !== $fields['client_id']) { + $changes[] = 'client changed'; + } + if ($changes) { + logAudit("Managed Instance", "Edit", "$session_name edited managed instance " . escapeSql($fields['name']) . " - " . escapeSql(implode(', ', $changes)), $fields['client_id'], $instance_id); + } + + flashAlert("" . escapeHtml($fields['name']) . " " . ($changes ? 'saved' . (!empty($row['managed_instance_secret_hash']) ? ' - it takes the change at its next check-in' : '') : 'unchanged')); + + redirect(); + +} + +if (isset($_GET['managed_instance_new_code'])) { + + validateCSRFToken(); + + $row = $managed_instance_for_request($_GET['managed_instance_new_code']); + $instance_id = intval($row['managed_instance_id']); + + $_SESSION['managed_instance_code'][$instance_id] = managedInstanceNewCode($instance_id); + + logAudit("Managed Instance", "Code", "$session_name made a new connection code for " . escapeSql($row['managed_instance_name']), intval($row['managed_instance_client_id']), $instance_id); + + redirect("managed_instance.php?id=$instance_id"); + +} + +if (isset($_GET['managed_command'])) { + + validateCSRFToken(); + + $command = (string) $_GET['managed_command']; + if (!in_array($command, ['update_now', 'check_updates'], true)) { + flashAlert("That isn't something an instance can be asked", 'error'); + redirect(); + } + + $row = $managed_instance_for_request($_GET['id'] ?? 0, true); + $instance_id = intval($row['managed_instance_id']); + $by = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_name FROM users WHERE user_id = " . intval($session_user_id)))[0] ?? 'An admin'); + + managedQueueCommand($instance_id, $command, $by); + + logAudit("Managed Instance", $command === 'update_now' ? "Update" : "Check", "$session_name asked " . escapeSql($row['managed_instance_name']) . " to " . strtolower(managedCommandLabel($command)), intval($row['managed_instance_client_id']), $instance_id); + + flashAlert("Asked - " . escapeHtml($row['managed_instance_name']) . " picks it up at its next check-in, within a minute"); + + redirect(); + +} + +if (isset($_GET['managed_instance_mode'])) { + + validateCSRFToken(); + + $mode = (string) $_GET['managed_instance_mode']; + if (!isset(MANAGED_MODES[$mode])) { + flashAlert("That isn't a mode", 'error'); + redirect(); + } + + $row = $managed_instance_for_request($_GET['id'] ?? 0); + $instance_id = intval($row['managed_instance_id']); + + mysqli_query($mysqli, "UPDATE managed_instances SET managed_instance_mode = '$mode' WHERE managed_instance_id = $instance_id"); + + logAudit("Managed Instance", "Mode", "$session_name made " . escapeSql($row['managed_instance_name']) . " " . strtolower(MANAGED_MODES[$mode]) . " (was " . strtolower(MANAGED_MODES[$row['managed_instance_mode']] ?? $row['managed_instance_mode']) . ")", intval($row['managed_instance_client_id']), $instance_id); + + flashAlert("" . escapeHtml($row['managed_instance_name']) . " is " . strtolower(MANAGED_MODES[$mode]) . (!empty($row['managed_instance_secret_hash']) ? ' from its next check-in' : ' - it takes it when it connects')); + + redirect(); + +} + +if (isset($_GET['managed_support_sign_in'])) { + + validateCSRFToken(); + + $row = $managed_instance_for_request($_GET['managed_support_sign_in'], true); + $instance_id = intval($row['managed_instance_id']); + $me = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_email FROM users WHERE user_id = " . intval($session_user_id))) ?: []; + + try { + $link = managedSupportLink($row, (string) ($me['user_name'] ?? ''), (string) ($me['user_email'] ?? '')); + } catch (Throwable $e) { + flashAlert(escapeHtml($e->getMessage()), 'error'); + redirect("managed_instance.php?id=$instance_id"); + } + + logAudit("Managed Instance", "Support", "$session_name signed in to " . escapeSql($row['managed_instance_name']) . " as support", intval($row['managed_instance_client_id']), $instance_id); + + redirect($link); + +} + +if (isset($_GET['remove_managed_instance'])) { + + validateCSRFToken(); + + $row = $managed_instance_for_request($_GET['remove_managed_instance']); + $instance_id = intval($row['managed_instance_id']); + + mysqli_query($mysqli, "UPDATE managed_instances SET managed_instance_archived_at = NOW(), managed_instance_enroll_hash = NULL, managed_instance_enroll_expires_at = NULL + WHERE managed_instance_id = $instance_id"); + // What it hasn't picked up, it won't now + mysqli_query($mysqli, "UPDATE managed_instance_commands SET managed_command_status = 'cancelled', managed_command_result = 'The instance was removed', managed_command_done_at = NOW() + WHERE managed_command_instance_id = $instance_id AND managed_command_status = 'queued'"); + + logAudit("Managed Instance", "Remove", "$session_name removed managed instance " . escapeSql($row['managed_instance_name']), intval($row['managed_instance_client_id']), $instance_id); + + flashAlert("" . escapeHtml($row['managed_instance_name']) . " removed" . (!empty($row['managed_instance_secret_hash']) ? ' - it lets go at its next check-in' : ''), 'error'); + + redirect('managed_instances.php'); + +} + +if (isset($_GET['delete_managed_instance'])) { + + validateCSRFToken(); + + $row = managedInstanceRow(intval($_GET['delete_managed_instance'])); + if (!$row || empty($row['managed_instance_archived_at'])) { + flashAlert("Only a removed instance can be deleted", 'error'); + redirect('managed_instances.php'); + } + $instance_id = intval($row['managed_instance_id']); + + mysqli_query($mysqli, "DELETE FROM managed_instance_commands WHERE managed_command_instance_id = $instance_id"); + mysqli_query($mysqli, "DELETE FROM managed_instances WHERE managed_instance_id = $instance_id"); + + logAudit("Managed Instance", "Delete", "$session_name deleted managed instance " . escapeSql($row['managed_instance_name']), intval($row['managed_instance_client_id']), $instance_id); + + flashAlert("" . escapeHtml($row['managed_instance_name']) . " deleted", 'error'); + + redirect('managed_instances.php?show=removed'); + +} diff --git a/admin/post/settings_module.php b/admin/post/settings_module.php index 728964209..eec5ec4c6 100644 --- a/admin/post/settings_module.php +++ b/admin/post/settings_module.php @@ -5,7 +5,8 @@ * edit_module_settings saves which modules are in use, and the white-label * key. A key that isn't one is refused rather than saved; the key already in * use, run out, can stay while the modules are saved - it is just not in - * force. Empty, white-labelling is off. + * force. Empty, white-labelling is off. On a managed instance whose manager + * sets the key, the key is left as the manager set it. */ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); @@ -24,7 +25,9 @@ if (isset($_POST['edit_module_settings'])) { $key = trim((string) ($_POST['config_whitelabel_key'] ?? '')); $errors = []; - if ($key === '') { + if (managedLocks()['white_label']) { + // Set by the manager of this managed instance - not this page's to change (functions/managed.php) + } elseif ($key === '') { $after['config_whitelabel_key'] = ''; $after['config_whitelabel_enabled'] = 0; } elseif (validateWhitelabelKey($key)) { diff --git a/admin/post/update.php b/admin/post/update.php index 8db0186c9..fb7893c57 100644 --- a/admin/post/update.php +++ b/admin/post/update.php @@ -46,6 +46,12 @@ if (isset($_GET['queue_update'])) { enforceAdminPermission(); + // A managed instance is updated by its manager (functions/managed.php) + if (managedLocks()['updates']) { + flashAlert(escapeHtml(managedManagerName()) . " manages this ITFlow, so updates come from there.", 'error'); + redirect(); + } + // The files can be newer than the schema - that is the window this whole page exists to // close - and the column the queue is written to arrives with a migration. There is no // longer a button that applies migrations on their own, so the way out is the shell. diff --git a/admin/settings_module.php b/admin/settings_module.php index 5dcbc340f..29b299487 100644 --- a/admin/settings_module.php +++ b/admin/settings_module.php @@ -14,6 +14,9 @@ $whitelabel_key = trim((string) $config_whitelabel_key); $whitelabel = $whitelabel_key !== '' ? validateWhitelabelKey($whitelabel_key) : false; $whitelabel_expires = $whitelabel ? strtotime((string) $whitelabel['expires']) : 0; +// A managed instance whose manager sets the key - shown, not changed here (functions/managed.php) +$whitelabel_managed = managedLocks()['white_label']; + $modules = [ 'config_module_enable_itdoc' => [$config_module_enable_itdoc, 'fa-laptop', 'A client\'s assets, credentials, documents, networks, domains, certificates and the rest of their IT documentation.'], 'config_module_enable_ticketing' => [$config_module_enable_ticketing, 'fa-life-ring', 'Tickets, recurring tickets and projects - and their settings.'], @@ -62,7 +65,11 @@ echo settingsPageStart('module');
    The key below has run out, so "Powered by ITFlow" is back.
    - 3, 'placeholder' => 'Paste the key here', 'help' => 'Left empty, "Powered by ITFlow" shows.']) ?> + +

    The key is set by , who manages this ITFlow - it can't be changed here.

    + + 3, 'placeholder' => 'Paste the key here', 'help' => 'Left empty, "Powered by ITFlow" shows.']) ?> + diff --git a/admin/update.php b/admin/update.php index 6744e12d1..61184685a 100644 --- a/admin/update.php +++ b/admin/update.php @@ -147,8 +147,12 @@ if ($update_running) { $state = 'current'; } +// A managed instance is updated by its manager, not from here (functions/managed.php) +$updates_managed = managedLocks()['updates']; +$updates_policy = managedState(); + // Update ITFlow where there is something to update (or no telling), Check where a check can run -$show_update_button = in_array($state, ['database', 'available', 'unknown'], true) && $update_queue_available; +$show_update_button = in_array($state, ['database', 'available', 'unknown'], true) && $update_queue_available && !$updates_managed; $show_check_button = in_array($state, ['available', 'unknown', 'current'], true) && $update_check_available && $is_git_checkout; $queue_url = "post.php?queue_update&csrf_token=" . urlencode($_SESSION['csrf_token']); @@ -279,6 +283,15 @@ $hero = [ + +

    + manages this ITFlow, so it is updated from there 0 ? $updates_policy['update_delay_days'] . ' day' . ($updates_policy['update_delay_days'] === 1 ? '' : 's') . ' after a release comes out' : 'as soon as a release comes out') . ', and straight away for security fixes'; + } ?>. +

    + +
    diff --git a/admin/users.php b/admin/users.php index 509d07a02..ae913904c 100644 --- a/admin/users.php +++ b/admin/users.php @@ -122,6 +122,10 @@ $agent_user_count = getActiveAgentUserCount(); $agent_invites_holding = userInvitesHoldingSeats(); $agent_user_limit_reached = $agent_user_limit > 0 && $agent_user_count + $agent_invites_holding >= $agent_user_limit; +// A managed instance - the user its manager signs in as for support, and the limit when the manager sets it +$managed_support_id = managedSupportUserId(); +$managed_limit_by = managedLocks()['seat_limit'] ? managedManagerName() : ''; + // An invite link to pass on - shown this once $invite_link = userInviteTakeLink(); $invite_mail_ready = userInviteMailReady(); @@ -146,7 +150,7 @@ $csrf = $_SESSION['csrf_token'];

    Users 0) { ?> , of the agent user limit set for this installation"> + title=" active agent, of the agent user limit "> of seats @@ -414,7 +418,7 @@ $csrf = $_SESSION['csrf_token']; ); $remember_count = $remembered[$user_id] ?? 0; ?> - +
    @@ -432,6 +436,7 @@ $csrf = $_SESSION['csrf_token'];
    You + Support
    diff --git a/api/managed/bootstrap.php b/api/managed/bootstrap.php new file mode 100644 index 000000000..980ee6337 --- /dev/null +++ b/api/managed/bootstrap.php @@ -0,0 +1,40 @@ + 'POST only']); +} + +// The manager's side arrives with a migration - before it, there is nothing to answer +if (!settingsColumnExists($mysqli, 'config_manager_keys')) { + $managed_api_reply(503, ['error' => "The manager's database update hasn't been applied yet"]); +} + +$managed_api_body = json_decode((string) file_get_contents('php://input'), true); +if (!is_array($managed_api_body)) { + $managed_api_reply(400, ['error' => 'Expected JSON']); +} + +// logAudit() reads these +$session_ip = escapeSql(getIP()); +$session_user_agent = escapeSql($_SERVER['HTTP_USER_AGENT'] ?? ''); +$session_user_id = 0; +$session_name = 'Managed instance'; diff --git a/api/managed/checkin.php b/api/managed/checkin.php new file mode 100644 index 000000000..52f2d4f93 --- /dev/null +++ b/api/managed/checkin.php @@ -0,0 +1,21 @@ + $managed_value) { + if (strtolower($managed_header) === 'authorization') { + $managed_authorization = (string) $managed_value; + } + } +} + +[$managed_status, $managed_answer] = managerCheckin($managed_authorization, $managed_api_body, getIP()); + +$managed_api_reply($managed_status, $managed_answer); diff --git a/api/managed/enroll.php b/api/managed/enroll.php new file mode 100644 index 000000000..29a729eac --- /dev/null +++ b/api/managed/enroll.php @@ -0,0 +1,12 @@ + + * Managed Instances. See managerEnroll() in functions/managed.php. + */ + +require_once __DIR__ . '/bootstrap.php'; + +[$managed_status, $managed_answer] = managerEnroll($managed_api_body, getIP()); + +$managed_api_reply($managed_status, $managed_answer); diff --git a/api/v1/validate_api_key.php b/api/v1/validate_api_key.php index a1c3acf23..67758fec3 100644 --- a/api/v1/validate_api_key.php +++ b/api/v1/validate_api_key.php @@ -17,6 +17,9 @@ require_once __DIR__ . "/../../includes/inc_set_timezone.php"; // JSON header header('Content-Type: application/json'); +// A managed instance its manager has made read-only or suspended (functions/managed.php) +managedRequestGuard('api'); + // POST data $_POST = json_decode(file_get_contents('php://input'), true); diff --git a/client/includes/check_login.php b/client/includes/check_login.php index 8520a2e8f..8b09f33b9 100644 --- a/client/includes/check_login.php +++ b/client/includes/check_login.php @@ -154,3 +154,6 @@ $client_sql = mysqli_query($mysqli, "SELECT client_name FROM clients WHERE clien $client = mysqli_fetch_assoc($client_sql); $session_client_name = $client['client_name']; + +// A managed instance its manager has made read-only or suspended (functions/managed.php) +managedRequestGuard('portal'); diff --git a/client/includes/header.php b/client/includes/header.php index a9331b04d..0cb8d1417 100644 --- a/client/includes/header.php +++ b/client/includes/header.php @@ -233,6 +233,8 @@ $portal_contact_name = (string) ($contact['contact_name'] ?? '');
    + + 'Running') + AND ($due_clause)"); + return false; + } + mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_last_run_at = '$now', cron_job_last_status = 'Running', @@ -232,7 +242,14 @@ foreach (cronJobRegistry() as $cron_dispatch_job) { continue; } - if (!cronJobClaim($mysqli, $cron_dispatch_job)) { + // A managed instance its manager has made read-only or suspended runs only the jobs that + // keep it looked after (MANAGED_KEEP_JOBS) - the rest wait, due, until it's active again. + // Asked for each job, as the Manager Check-in job at the top of the list can change it. + $cron_dispatch_mode = managedMode(); + $cron_dispatch_paused = $cron_dispatch_mode !== 'active' && !in_array($cron_dispatch_job['name'], MANAGED_KEEP_JOBS[$cron_dispatch_mode] ?? [], true) + ? 'Paused - this ITFlow is ' . ($cron_dispatch_mode === 'suspended' ? 'suspended' : 'read-only') . ' by its manager' : ''; + + if (!cronJobClaim($mysqli, $cron_dispatch_job, $cron_dispatch_paused)) { cronLockRelease($cron_dispatch_lock); continue; } diff --git a/cron/managed_instances.php b/cron/managed_instances.php new file mode 100644 index 000000000..b90f3b4f5 --- /dev/null +++ b/cron/managed_instances.php @@ -0,0 +1,80 @@ + Managed Instances): notices what the instances + * don't say themselves. An instance that has stopped checking in gets one notification, until + * it's back (the check-in clears it - managerCheckin()); a request it took and never answered, + * or an update that never said it finished, is closed as failed rather than left waiting. + */ + +// Set working directory to the directory this cron script lives at. +chdir(dirname(__FILE__)); + +// Ensure we're running from command line +if (php_sapi_name() !== 'cli') { + die("This script must be run from the command line.\n"); +} + +// Prevent overlapping runs of this script +$cron_lock_script = __FILE__; +require_once "includes/cron_lock.php"; + +require_once "../config.php"; + +// Set Timezone +require_once "../includes/inc_set_timezone.php"; +require_once "../functions.php"; + +$managed_instances_settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1")); + +if (intval($managed_instances_settings['config_enable_cron'] ?? 0) === 0) { + cronJobStop("Cron: is not enabled\n"); +} + +// Files newer than the schema - the tables arrive with the migration +if (!settingsColumnExists($mysqli, 'config_manager_keys')) { + cronJobStop("The database update has not been applied yet\n"); +} + +$managed_instances_count = 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"))[0] ?? 0); + +if ($managed_instances_count === 0) { + cronJobStop("No instances connected\n"); +} + +// Gone quiet - told once, until it checks in again +$managed_instances_quiet = mysqli_query($mysqli, "SELECT managed_instance_id, managed_instance_name, managed_instance_client_id, managed_instance_last_checkin_at FROM managed_instances + WHERE managed_instance_archived_at IS NULL AND managed_instance_secret_hash IS NOT NULL AND managed_instance_offline_notified_at IS NULL + AND (managed_instance_last_checkin_at IS NULL OR managed_instance_last_checkin_at < NOW() - INTERVAL " . MANAGED_OFFLINE_MINUTES . " MINUTE)"); + +while ($managed_instance = mysqli_fetch_assoc($managed_instances_quiet)) { + $managed_instance_id = intval($managed_instance['managed_instance_id']); + $managed_instance_last = $managed_instance['managed_instance_last_checkin_at']; + + appNotify("Managed Instance", escapeSql($managed_instance['managed_instance_name'] . " has stopped checking in" . ($managed_instance_last ? " - last " . date('M j, g:i A', strtotime($managed_instance_last)) : '')), + "/admin/managed_instance.php?id=$managed_instance_id", intval($managed_instance['managed_instance_client_id']), $managed_instance_id); + + mysqli_query($mysqli, "UPDATE managed_instances SET managed_instance_offline_notified_at = NOW() WHERE managed_instance_id = $managed_instance_id"); +} + +// A request it took and never answered +mysqli_query($mysqli, "UPDATE managed_instance_commands SET managed_command_status = 'failed', managed_command_done_at = NOW(), + managed_command_result = 'It took the request but never answered - see whether it is still checking in' + WHERE managed_command_status = 'sent' AND managed_command_sent_at < NOW() - INTERVAL 1 HOUR"); + +// An update that never said it finished +$managed_instances_stuck = mysqli_query($mysqli, "SELECT managed_command_id, managed_instance_id, managed_instance_name, managed_instance_client_id + FROM managed_instance_commands JOIN managed_instances ON managed_instance_id = managed_command_instance_id + WHERE managed_command = 'update_now' AND managed_command_status = 'running' AND managed_command_sent_at < NOW() - INTERVAL 2 HOUR"); + +while ($managed_instance = mysqli_fetch_assoc($managed_instances_stuck)) { + mysqli_query($mysqli, "UPDATE managed_instance_commands SET managed_command_status = 'failed', managed_command_done_at = NOW(), + managed_command_result = 'No word of the update finishing in two hours - look at its Maintenance > Update page' + WHERE managed_command_id = " . intval($managed_instance['managed_command_id'])); + + appNotify("Managed Instance", escapeSql("The update on " . $managed_instance['managed_instance_name'] . " hasn't finished in two hours"), + "/admin/managed_instance.php?id=" . intval($managed_instance['managed_instance_id']), intval($managed_instance['managed_instance_client_id']), intval($managed_instance['managed_instance_id'])); +} diff --git a/cron/manager_checkin.php b/cron/manager_checkin.php new file mode 100644 index 000000000..7d20848f0 --- /dev/null +++ b/cron/manager_checkin.php @@ -0,0 +1,53 @@ + Managed By): every minute, tells the manager how + * this install is doing and takes back what the manager sets - seat limit, white label, + * updates, active, read-only or suspended - and anything it has asked, like an update. See + * functions/managed.php. The instance always calls; the manager never calls it. + * + * First in the registry, so a change of mode applies to the jobs after it in the same cycle. + * Stops straight away on an install that isn't managed - which is most of them. + */ + +// Set working directory to the directory this cron script lives at. +chdir(dirname(__FILE__)); + +// Ensure we're running from command line +if (php_sapi_name() !== 'cli') { + die("This script must be run from the command line.\n"); +} + +// Prevent overlapping runs of this script +$cron_lock_script = __FILE__; +require_once "includes/cron_lock.php"; + +require_once "../config.php"; + +// Set Timezone +require_once "../includes/inc_set_timezone.php"; +require_once "../functions.php"; + +$manager_checkin_settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1")); + +if (intval($manager_checkin_settings['config_enable_cron'] ?? 0) === 0) { + cronJobStop("Cron: is not enabled\n"); +} + +// Files newer than the schema - the column managed mode keeps its state in isn't there yet +if (!settingsColumnExists($mysqli, 'config_managed')) { + cronJobStop("The database update has not been applied yet\n"); +} + +if (!managedIsManaged()) { + cronJobStop("Not managed\n"); +} + +$manager_checkin = managedCheckin(); + +// Thrown, so the dispatcher records why against the job and Admin > Managed By can show it +if (!$manager_checkin['ok']) { + throw new Exception($manager_checkin['error']); +} diff --git a/cron/ticket_email_parser.php b/cron/ticket_email_parser.php index 5f4578a09..8e93ebaea 100644 --- a/cron/ticket_email_parser.php +++ b/cron/ticket_email_parser.php @@ -42,15 +42,14 @@ $row = mysqli_fetch_assoc($sql); $company_name = escapeSql($row['company_name']); $company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code'])); -// Check cron is enabled +// Check cron is enabled. Switched off isn't an error - Maintenance > Cron shows why it stopped, +// where an App Logs entry every minute only buried the real errors if ($config_enable_cron == 0) { - logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - cron not enabled in admin settings."); cronJobStop("Cron: is not enabled -- Quitting.."); } // Check setting enabled if ($config_ticket_email_parse == 0) { - logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - not enabled in admin settings."); cronJobStop("Email Parser: Feature is not enabled - check Settings > Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting.."); } diff --git a/db.sql b/db.sql index 81d1a217b..fadc219b6 100644 --- a/db.sql +++ b/db.sql @@ -2016,6 +2016,70 @@ CREATE TABLE `logs` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `managed_instance_commands` +-- + +DROP TABLE IF EXISTS `managed_instance_commands`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `managed_instance_commands` ( + `managed_command_id` int(11) NOT NULL AUTO_INCREMENT, + `managed_command_instance_id` int(11) NOT NULL, + `managed_command` varchar(40) NOT NULL, + `managed_command_status` varchar(20) NOT NULL DEFAULT 'queued', + `managed_command_source` varchar(20) NOT NULL DEFAULT 'manual', + `managed_command_requested_by` varchar(200) DEFAULT NULL, + `managed_command_result` varchar(1000) DEFAULT NULL, + `managed_command_created_at` datetime NOT NULL DEFAULT current_timestamp(), + `managed_command_sent_at` datetime DEFAULT NULL, + `managed_command_done_at` datetime DEFAULT NULL, + PRIMARY KEY (`managed_command_id`), + KEY `managed_command_instance_id` (`managed_command_instance_id`,`managed_command_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `managed_instances` +-- + +DROP TABLE IF EXISTS `managed_instances`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `managed_instances` ( + `managed_instance_id` int(11) NOT NULL AUTO_INCREMENT, + `managed_instance_uuid` varchar(36) NOT NULL, + `managed_instance_name` varchar(200) NOT NULL, + `managed_instance_client_id` int(11) NOT NULL DEFAULT 0, + `managed_instance_notes` text DEFAULT NULL, + `managed_instance_enroll_hash` varchar(64) DEFAULT NULL, + `managed_instance_enroll_expires_at` datetime DEFAULT NULL, + `managed_instance_secret_hash` varchar(64) DEFAULT NULL, + `managed_instance_enrolled_at` datetime DEFAULT NULL, + `managed_instance_url` varchar(255) DEFAULT NULL, + `managed_instance_mode` varchar(20) NOT NULL DEFAULT 'active', + `managed_instance_mode_message` varchar(500) DEFAULT NULL, + `managed_instance_seat_limit` int(11) NOT NULL DEFAULT 0, + `managed_instance_white_label_key` text DEFAULT NULL, + `managed_instance_update_policy` varchar(20) NOT NULL DEFAULT 'manual', + `managed_instance_update_delay_days` int(11) NOT NULL DEFAULT 7, + `managed_instance_version` varchar(20) DEFAULT NULL, + `managed_instance_db_version` varchar(20) DEFAULT NULL, + `managed_instance_update_version` varchar(40) DEFAULT NULL, + `managed_instance_update_seen_at` datetime DEFAULT NULL, + `managed_instance_report` mediumtext DEFAULT NULL, + `managed_instance_last_checkin_at` datetime DEFAULT NULL, + `managed_instance_last_ip` varchar(45) DEFAULT NULL, + `managed_instance_offline_notified_at` datetime DEFAULT NULL, + `managed_instance_created_at` datetime NOT NULL DEFAULT current_timestamp(), + `managed_instance_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), + `managed_instance_archived_at` datetime DEFAULT NULL, + PRIMARY KEY (`managed_instance_id`), + UNIQUE KEY `managed_instance_uuid` (`managed_instance_uuid`), + KEY `managed_instance_client_id` (`managed_instance_client_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `modules` -- @@ -3187,6 +3251,8 @@ CREATE TABLE `settings` ( `config_update_pending_commits` text DEFAULT NULL, `config_update_checked_at` datetime DEFAULT NULL, `config_update_details` mediumtext DEFAULT NULL, + `config_manager_keys` text DEFAULT NULL, + `config_managed` mediumtext DEFAULT NULL, `config_share_max_hours` int(11) NOT NULL DEFAULT 720, `config_share_require_protection` tinyint(1) NOT NULL DEFAULT 0, `config_login_alert_failures` int(11) NOT NULL DEFAULT 10, diff --git a/functions.php b/functions.php index edb4d4fd0..11457deea 100644 --- a/functions.php +++ b/functions.php @@ -103,3 +103,4 @@ require_once __DIR__ . '/functions/ticket_mention.php'; require_once __DIR__ . '/functions/getting_started.php'; require_once __DIR__ . '/functions/settings.php'; require_once __DIR__ . '/functions/mail_queue.php'; +require_once __DIR__ . '/functions/managed.php'; diff --git a/functions/app.php b/functions/app.php index d2066e2ff..30392dd32 100644 --- a/functions/app.php +++ b/functions/app.php @@ -989,14 +989,23 @@ function getProductsForAutocomplete($mysqli, $client_id = 0): string * instance the provider controls config.php and the customer's admins do not, so * nobody inside the app can raise their own limit. * + * A managed instance's manager can set one instead (functions/managed.php), and when it does, + * it is the one that counts - that is where the instance's plan is kept. + * * An active agent is user_type 1, enabled and not archived. Disabled and archived * users do not use a seat. An invite waiting and in date does * (userInvitesHoldingSeats(), functions/user_invite.php) - it is a seat promised. + * The manager's support user doesn't: it is the manager's, not one of theirs. */ function getAgentUserLimit(): int { global $config_agent_user_limit; + $managed = managedSeatLimit(); + if ($managed > 0) { + return $managed; + } + return max(0, intval($config_agent_user_limit ?? 0)); } @@ -1004,7 +1013,10 @@ function getActiveAgentUserCount(): int { global $mysqli; - $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(user_id) FROM users WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL")); + $support_user_id = managedSupportUserId(); + + $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(user_id) FROM users WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL" + . ($support_user_id ? " AND user_id <> $support_user_id" : ''))); return intval($row[0]); } @@ -1026,8 +1038,9 @@ function agentUserLimitMessage(): string { $limit = getAgentUserLimit(); $invites = userInvitesHoldingSeats(); + $provider = managedSeatLimit() > 0 ? escapeHtml(managedManagerName()) : 'your provider'; return "Agent user limit reached - this installation allows $limit active agent " . ($limit == 1 ? "user" : "users") . ($invites ? ", and $invites invite" . ($invites === 1 ? ' is' : 's are') . " waiting for one" : '') - . ". Archive or disable a user" . ($invites ? ' or cancel an invite' : '') . " first, or contact your provider to add more."; + . ". Archive or disable a user" . ($invites ? ' or cancel an invite' : '') . " first, or contact $provider to add more."; } diff --git a/functions/audit_log.php b/functions/audit_log.php index 308c1efd8..4e6a4f1fd 100644 --- a/functions/audit_log.php +++ b/functions/audit_log.php @@ -59,6 +59,7 @@ function auditLogKinds() { 'Document Template' => ['icon' => 'fa-file-alt', 'table' => 'document_templates', 'id' => 'document_template_id', 'name' => 'document_template_name', 'url' => '/admin/document_template.php?document_template_id={id}'], 'Software Template' => ['icon' => 'fa-cube', 'table' => 'software_templates', 'id' => 'software_template_id', 'name' => 'software_template_name', 'url' => '/admin/software_template.php?software_template_id={id}'], 'Vendor Template' => ['icon' => 'fa-building', 'table' => 'vendor_templates', 'id' => 'vendor_template_id', 'name' => 'vendor_template_name', 'url' => '/admin/vendor_template.php?vendor_template_id={id}'], + 'Managed Instance' => ['icon' => 'fa-network-wired', 'table' => 'managed_instances', 'id' => 'managed_instance_id', 'name' => 'managed_instance_name', 'url' => '/admin/managed_instance.php?id={id}'], // Not one record to open, or nothing to open it with - just an icon 'Login' => ['icon' => 'fa-sign-in-alt'], @@ -117,6 +118,7 @@ function auditLogKinds() { 'Sharing' => ['icon' => 'fa-share-alt'], 'Audit Log' => ['icon' => 'fa-history'], 'App Log' => ['icon' => 'fa-clipboard-list'], + 'Management' => ['icon' => 'fa-handshake'], ]; } @@ -151,6 +153,8 @@ function auditLogActionStyle($action) { 'Check' => ['info', 'fa-check'], 'Review' => ['info', 'fa-clipboard-check'], 'Save' => ['success', 'fa-save'], 'Bill' => ['success', 'fa-file-invoice-dollar'], 'Renew' => ['success', 'fa-redo-alt'], 'Reimburse' => ['success', 'fa-hand-holding-usd'], 'Finish' => ['success', 'fa-flag-checkered'], 'Invite' => ['primary', 'fa-user-plus'], 'Onboard' => ['primary', 'fa-route'], 'Sign Up Requested' => ['secondary', 'fa-user-plus'], 'Sign Up Sent' => ['primary', 'fa-paper-plane'], 'Sign Up Refused' => ['warning', 'fa-user-times'], + 'Connect' => ['success', 'fa-plug'], 'Disconnect' => ['secondary', 'fa-unlink'], 'Remove' => ['danger', 'fa-unlink'], 'Release' => ['secondary', 'fa-unlink'], 'Code' => ['primary', 'fa-key'], + 'Mode' => ['warning', 'fa-lock'], 'Support' => ['warning', 'fa-headset'], ]; $style = $styles[$action] ?? ['secondary', 'fa-circle']; return ['tone' => $style[0], 'icon' => $style[1]]; diff --git a/functions/managed.php b/functions/managed.php new file mode 100644 index 000000000..b82e5ca7b --- /dev/null +++ b/functions/managed.php @@ -0,0 +1,1288 @@ + 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. + */ + +/* 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 */ + +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); +} + +function managedIsManaged(): 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, + ]; +} + +function managedSupportUserId(): int +{ + return managedIsManaged() ? 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; + + $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), + ], + '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 (!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()) { + 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, + * 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; + + if (empty($row['managed_instance_secret_hash'])) { + $state = 'pending'; + } elseif (!$last || strtotime($last) < strtotime('-' . MANAGED_OFFLINE_MINUTES . ' minutes')) { + $state = 'offline'; + } else { + $state = 'online'; + } + + $issues = []; + if ($state === 'offline') { + $issues[] = ['danger', $last ? 'Not checked in for ' . str_replace(' ago', '', timeAgo($last)) : 'Connected, but never checked in']; + } + if ($report) { + $cron = (array) ($report['cron'] ?? []); + 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), + ]; + + if ($fields['name'] === '') { + return [$fields, 'Give it a name.']; + } + 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']); +} + +/* 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; + + 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) . ''; +} diff --git a/functions/security.php b/functions/security.php index c9c036ee3..8b06849f3 100644 --- a/functions/security.php +++ b/functions/security.php @@ -79,7 +79,7 @@ function encryptUserSpecificKey($user_password) { $salt = randomString(); // Get the session info. - $user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext']; + $user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext'] ?? ''; $user_encryption_session_iv = base64_decode($_SESSION['user_encryption_session_iv'] ?? ''); $user_encryption_session_key = base64_decode($_COOKIE['user_encryption_session_key'] ?? ''); @@ -146,7 +146,7 @@ function decryptCredentialEntry($credential_password_ciphertext) { $credential_ciphertext = $salt = substr($credential_password_ciphertext, 16); // Get the user session info. - $user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext']; + $user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext'] ?? ''; $user_encryption_session_iv = base64_decode($_SESSION['user_encryption_session_iv'] ?? ''); $user_encryption_session_key = base64_decode($_COOKIE['user_encryption_session_key'] ?? ''); @@ -162,7 +162,7 @@ function encryptCredentialEntry($credential_password_cleartext) { $iv = randomString(); // Get the user session info. - $user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext']; + $user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext'] ?? ''; $user_encryption_session_iv = base64_decode($_SESSION['user_encryption_session_iv'] ?? ''); $user_encryption_session_key = base64_decode($_COOKIE['user_encryption_session_key'] ?? ''); diff --git a/includes/check_login.php b/includes/check_login.php index f320b4d20..1baa1c206 100644 --- a/includes/check_login.php +++ b/includes/check_login.php @@ -8,3 +8,7 @@ require_once "load_user_session.php"; require_once "load_company_settings.php"; require_once "load_global_settings.php"; require_once "detect_device_type.php"; + +// A managed instance its manager has made read-only or suspended, and support sessions from +// the manager (functions/managed.php) +managedRequestGuard('agent'); diff --git a/includes/cron_jobs.php b/includes/cron_jobs.php index 3ec554ede..bc88de684 100644 --- a/includes/cron_jobs.php +++ b/includes/cron_jobs.php @@ -36,13 +36,23 @@ * switch is off. It is not a dispatcher-level gate - a new job has to make the check * itself, and a job that skips it will keep running on an install that thinks cron is off. * - * Order matters: the dispatcher works down this list. app_update replaces the files every - * job is loaded from, so it stays at the end and ends the cycle behind itself. + * Order matters: the dispatcher works down this list. manager_checkin goes first, so what a + * manager sets - read-only or suspended pause most jobs (MANAGED_KEEP_JOBS) - applies to the + * rest of the same cycle. app_update replaces the files every job is loaded from, so it stays + * at the end and ends the cycle behind itself. */ function cronJobRegistry(): array { return [ + [ + 'name' => 'manager_checkin', + 'label' => 'Manager Check-in', + 'script' => 'manager_checkin.php', + 'description' => 'For an ITFlow managed by another (Admin > Managed By): reports how it is doing and takes what its manager sets. Stops straight away when it isn\'t managed.', + 'schedule' => 'Interval', + 'interval_minutes' => 1, + ], [ 'name' => 'mail_queue', 'label' => 'Mail Queue', @@ -75,6 +85,14 @@ function cronJobRegistry(): array 'schedule' => 'Interval', 'interval_minutes' => 1, ], + [ + 'name' => 'managed_instances', + 'label' => 'Managed Instances', + 'script' => 'managed_instances.php', + 'description' => 'For an ITFlow that manages others (Admin > Managed Instances): notifies when one stops checking in, and closes requests that were never answered.', + 'schedule' => 'Interval', + 'interval_minutes' => 5, + ], [ 'name' => 'payment_provider_fees', 'label' => 'Payment Provider Fees', diff --git a/includes/inc_alert_feedback.php b/includes/inc_alert_feedback.php index 7b496ca1f..eaf11681d 100644 --- a/includes/inc_alert_feedback.php +++ b/includes/inc_alert_feedback.php @@ -113,3 +113,8 @@ if (!empty($_SESSION['alert_message'])) { } ?> + icon - the asset types, in order (ASSET_TYPES in functions/asset_type.php) $asset_types_array = function_exists('assetTypeIcons') ? assetTypeIcons() : []; + +// A managed instance its manager has made read-only or suspended (functions/managed.php). The +// pages anyone can open - guest links, signing up, joining, password resets - are checked here, +// as nearly all of them load this file. Client portal pages that load it before functions.php +// are checked by client/includes/check_login.php instead +if (PHP_SAPI !== 'cli' && function_exists('managedRequestGuard')) { + managedRequestGuard(str_starts_with((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/api/') ? 'api' : 'public'); +} diff --git a/includes/managed_blocked.php b/includes/managed_blocked.php new file mode 100644 index 000000000..9078740f9 --- /dev/null +++ b/includes/managed_blocked.php @@ -0,0 +1,78 @@ + + + + + + <?= escapeHtml($managed_blocked_name) ?> | <?= escapeHtml($managed_blocked_title) ?> + + + + + + + + + + + + + + + diff --git a/login.php b/login.php index 048ad7f14..713f676bd 100644 --- a/login.php +++ b/login.php @@ -31,6 +31,9 @@ if ( require_once "includes/inc_set_timezone.php"; +// A managed instance its manager has suspended shows why, rather than a sign-in that leads nowhere +managedRequestGuard('login'); + $session_ip = escapeSql(getIP()); $session_user_agent = escapeSql($_SERVER['HTTP_USER_AGENT'] ?? ''); diff --git a/login_support.php b/login_support.php new file mode 100644 index 000000000..5b7d8807d --- /dev/null +++ b/login_support.php @@ -0,0 +1,79 @@ + $support_manager, + 'name' => $support_who['name'], + 'email' => $support_who['email'], + 'started' => time(), +]; + +$session_user_id = $support_user_id; +logAudit("Login", "Support", escapeSql("Support sign-in from $support_manager" . ($support_label !== '' ? " - $support_label" : '')), 0, $support_user_id); +appNotify("Management", escapeSql(($support_label !== '' ? $support_label : 'Support') . " from $support_manager signed in for support"), "/admin/audit_logs.php", 0, $support_user_id); + +$support_start_page = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_start_page FROM settings WHERE company_id = 1"))[0] ?? ''); +redirect("/agent/" . (preg_match('/^[a-z0-9_\/.?=&-]+$/i', $support_start_page) ? $support_start_page : 'clients.php'));