diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6644c5b..9b84745 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,32 +20,30 @@ When generating code for this repository: ### Key Dependencies - Cacti core framework (`api_plugin_*`, `db_*`, `read_config_option()`, `get_filter_request_var()`) -- `themes/` CSS overlays, `sounds/` audible alert assets, `js/` client-side dashboard code +- `css/` CSS overlays, `sounds/` audible alert assets, `js/` client-side dashboard code ## Project Structure ``` -monitor/ # Repository root (install to plugins/monitor/ in Cacti) -├── js/ # Dashboard client-side logic -├── sounds/ # Alert sound assets -├── themes/ # CSS theme overlays (monitor.css) -├── tests/ # Test suite -├── db_functions.php # SQL filter/join helpers and status/device query utilities -├── monitor.php # Web entrypoint/bootstrap (session/request setup) -├── monitor_controller.php # Action flow, filter handling, page orchestration -├── monitor_render.php # Dashboard/group rendering and view-specific output -├── poller_functions.php # Poller helper logic (uptime checks, notifications, email payloads) -├── poller_monitor.php # Background poller entry point (CLI: --help, --version, --debug) -├── INFO # Plugin metadata (name, version, compat) +monitor/ # Repository root (install to plugins/monitor/ in Cacti) +├── js/ # Dashboard client-side logic +├── sounds/ # Alert sound assets +├── css/ # CSS theme overlays (monitor.css) +├── tests/ # Test suite +├── includes/ # functions.php (DB/query helpers), controller.php (action flow), render.php (rendering), database.php (schema) +├── monitor.php # Web entrypoint/bootstrap (session/request setup) +├── poller_functions.php # Poller helper logic (uptime checks, notifications, email payloads) +├── poller_monitor.php # Background poller entry point (CLI: --help, --version, --debug) +├── INFO # Plugin metadata (name, version, compat) ├── README.md -└── setup.php # Plugin install/uninstall/upgrade hooks, hook registration, config +└── setup.php # Plugin install/uninstall/upgrade hooks, hook registration, config ``` ## Naming Conventions ### Function Names - Procedural functions use **lowerCamelCase**, the `monitor_` prefix, or the required `plugin_monitor_` lifecycle prefix, matching current file conventions — keep plugin hook callback names exactly synchronized between their definitions and their `api_plugin_register_hook()` registration strings. -- Keep top-level entrypoints lightweight; place reusable logic in the appropriate helper file (`db_functions.php`, `poller_functions.php`). +- Keep top-level entrypoints lightweight; place reusable logic in the appropriate helper file (`includes/functions.php`, `poller_functions.php`). ### Database Tables Plugin tables are prefixed `plugin_monitor_`. Reuse existing table names and avoid introducing parallel schema variants; keep schema evolution inside the existing setup/upgrade lifecycle functions in `setup.php`. @@ -100,9 +98,9 @@ Use gettext calls with the `monitor` domain for user-facing strings: `__('Text', ### File Responsibilities - `setup.php`: plugin lifecycle, hook registration, config arrays/settings, install/upgrade table management. - `monitor.php`: web entrypoint/bootstrap, includes, session/request setup. -- `monitor_controller.php`: action flow, filter handling, page orchestration. -- `monitor_render.php`: dashboard/group rendering and view-specific output. -- `db_functions.php`: SQL filter/join helpers and status/device query utilities. +- `includes/controller.php`: action flow, filter handling, page orchestration. +- `includes/render.php`: dashboard/group rendering and view-specific output. +- `includes/functions.php`: SQL filter/join helpers and status/device query utilities. - `poller_monitor.php`: CLI poller entrypoint. - `poller_functions.php`: poller helper logic (uptime checks, notifications, email payload building). @@ -175,3 +173,7 @@ existing code or adding new code, not just in dedicated cleanup passes: line, `@param` lines, a blank comment line, then `@return`. Infer parameter/return types from actual usage; don't change the function's real type-hints in the same pass (let static analysis flag mismatches separately). Skip vendored third-party library files. + +## File manifest & upgrade pruning + +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `monitor_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index ea03045..7ce0fb8 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -88,6 +88,9 @@ jobs: - name: Check PHP version run: php -v + - name: Validate plugin manifest (expected-file drift) + run: php cacti/plugins/monitor/tests/bin/validate-manifest.php + - name: Run apt-get update run: sudo apt-get update diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1e51e..bd799cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Changelog +--- 3.0 --- + +* feature: Adopt the Cacti plugin table API and relocate schema provisioning into includes/database.php +* feature: Add a fleet-wide manifest.json with upgrade-time file pruning +* feature: Consolidate per-theme override stylesheets from themes/ into css/ +* dev: Rename includes/db_functions.php to includes/functions.php and move bundled libraries under includes/ +* dev: Set the Cacti compatibility floor to 1.2.29 + --- 2.9 --- * dev: Measure CI coverage with xdebug instead of pcov so the plugin's own sources are instrumented (pcov auto-scopes to the Composer root and skipped cacti/plugins/, leaving the patch-coverage gate with nothing to measure) diff --git a/INFO b/INFO index 8642661..c5bc2f0 100644 --- a/INFO +++ b/INFO @@ -21,11 +21,11 @@ [info] name = monitor -version = 2.9 +version = 3.0 longname = Device Monitoring author = The Cacti Group email = homepage = http://www.cacti.net -compat = 1.2.32 +compat = 1.2.29 requires = thold:1.2.1 capabilities = online_view:1, online_mgmt:1, offline_view:0, offline_mgmt:0, remote_collect:0 diff --git a/themes/classic/monitor.css b/css/classic.css similarity index 100% rename from themes/classic/monitor.css rename to css/classic.css diff --git a/themes/dark/monitor.css b/css/dark.css similarity index 100% rename from themes/dark/monitor.css rename to css/dark.css diff --git a/themes/classic/index.php b/css/index.php similarity index 100% rename from themes/classic/index.php rename to css/index.php diff --git a/themes/midwinter/monitor.css b/css/midwinter.css similarity index 100% rename from themes/midwinter/monitor.css rename to css/midwinter.css diff --git a/themes/modern/monitor.css b/css/modern.css similarity index 100% rename from themes/modern/monitor.css rename to css/modern.css diff --git a/themes/paper-plane/monitor.css b/css/paper-plane.css similarity index 100% rename from themes/paper-plane/monitor.css rename to css/paper-plane.css diff --git a/themes/paw/monitor.css b/css/paw.css similarity index 100% rename from themes/paw/monitor.css rename to css/paw.css diff --git a/themes/sunrise/monitor.css b/css/sunrise.css similarity index 100% rename from themes/sunrise/monitor.css rename to css/sunrise.css diff --git a/monitor_controller.php b/includes/controller.php similarity index 99% rename from monitor_controller.php rename to includes/controller.php index 23cfaa8..7441fcc 100644 --- a/monitor_controller.php +++ b/includes/controller.php @@ -1047,8 +1047,8 @@ function monitorGetAjaxStatusLinks(array $host, array $config): string { } if (api_plugin_is_enabled('syslog') && api_plugin_user_realm_auth('syslog.php')) { - include($config['base_path'] . '/plugins/syslog/config.php'); - include_once($config['base_path'] . '/plugins/syslog/functions.php'); + require($config['base_path'] . '/plugins/syslog/config.php'); + require_once($config['base_path'] . '/plugins/syslog/functions.php'); $syslog_logs = syslog_db_fetch_cell_prepared( 'SELECT count(*) diff --git a/includes/database.php b/includes/database.php new file mode 100644 index 0000000..146d480 --- /dev/null +++ b/includes/database.php @@ -0,0 +1,204 @@ + Table definition consumed by + * api_plugin_db_table_create()/db_update_table(). + */ +function monitor_notify_history_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true]; + $data['columns'][] = ['name' => 'host_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'notify_type', 'type' => 'tinyint(3)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ping_time', 'type' => 'double', 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ping_threshold', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'notification_time', 'type' => 'timestamp', 'NULL' => false, 'default' => '0000-00-00 00:00:00']; + $data['columns'][] = ['name' => 'notes', 'type' => 'varchar(255)', 'NULL' => true, 'default' => null]; + $data['primary'] = ['id']; + $data['unique_keys'][] = ['name' => 'unique_key', 'columns' => ['host_id', 'notify_type', 'notification_time']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Stores Notification Event History'; + + return $data; +} + +/** + * The plugin_monitor_reboot_history table definition (one row per detected + * device reboot). + * + * @return array Table definition. + */ +function monitor_reboot_history_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true]; + $data['columns'][] = ['name' => 'host_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'reboot_time', 'type' => 'timestamp', 'NULL' => false, 'default' => '0000-00-00 00:00:00']; + $data['columns'][] = ['name' => 'log_time', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; + $data['primary'] = ['id']; + $data['keys'][] = ['name' => 'host_id', 'columns' => ['host_id']]; + $data['keys'][] = ['name' => 'log_time', 'columns' => ['log_time']]; + $data['keys'][] = ['name' => 'reboot_time', 'columns' => ['reboot_time']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Keeps Track of Device Reboot Times'; + + return $data; +} + +/** + * The plugin_monitor_uptime table definition (last known uptime per + * device). Uses the current column layout (uptime NOT NULL). + * + * @return array Table definition. + */ +function monitor_uptime_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'host_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'default' => 0]; + $data['columns'][] = ['name' => 'uptime', 'type' => 'bigint(20)', 'unsigned' => true, 'NULL' => false, 'default' => 0]; + $data['primary'] = ['host_id']; + $data['keys'][] = ['name' => 'uptime', 'columns' => ['uptime']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Keeps Track of the Devices last uptime to track agent restarts and reboots'; + + return $data; +} + +/** + * The plugin_monitor_dashboards table definition (per-user predefined + * dashboards). + * + * @return array Table definition. + */ +function monitor_dashboards_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true]; + $data['columns'][] = ['name' => 'user_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => 0]; + $data['columns'][] = ['name' => 'name', 'type' => 'varchar(128)', 'NULL' => true, 'default' => '']; + $data['columns'][] = ['name' => 'url', 'type' => 'varchar(1024)', 'NULL' => true, 'default' => '']; + $data['primary'] = ['id']; + $data['keys'][] = ['name' => 'user_id', 'columns' => ['user_id']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Stores predefined dashboard information for a user or users'; + + return $data; +} + +/** + * Removes the plugin_db_changes 'create' ownership record for the user-owned + * dashboards table so api_plugin_db_changes_remove() (run by + * api_plugin_uninstall()) does not drop it, preserving user dashboards across + * an uninstall. + * + * @return void + */ +function monitor_disown_dashboards_table() { + db_execute("DELETE FROM plugin_db_changes + WHERE plugin = 'monitor' + AND `table` = 'plugin_monitor_dashboards' + AND method = 'create'"); +} + +/** + * Creates this plugin's own tables through Cacti's tracked plugin table + * API and ensures the core host table carries the monitoring columns this + * plugin relies on. Called from plugin_monitor_install() and (idempotently) + * from monitor_check_upgrade(). + * + * @return void + */ +function monitor_setup_table() { + api_plugin_db_table_create('monitor', 'plugin_monitor_notify_history', monitor_notify_history_table_data()); + api_plugin_db_table_create('monitor', 'plugin_monitor_reboot_history', monitor_reboot_history_table_data()); + api_plugin_db_table_create('monitor', 'plugin_monitor_uptime', monitor_uptime_table_data()); + api_plugin_db_table_create('monitor', 'plugin_monitor_dashboards', monitor_dashboards_table_data()); + + /* dashboards holds user data: create it but do not record plugin ownership, so uninstall preserves it. */ + monitor_disown_dashboards_table(); + + if (db_table_exists('host')) { + $row_format = db_fetch_cell("SELECT ROW_FORMAT + FROM information_schema.tables + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'host'"); + + if (strtoupper((string) $row_format) !== 'DYNAMIC') { + db_execute('ALTER TABLE host ROW_FORMAT=DYNAMIC'); + } + } + + db_execute('SET SESSION innodb_strict_mode=0'); + + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor', 'type' => 'char(3)', 'NULL' => true, 'default' => 'on']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_text', 'type' => 'text', 'NULL' => false]); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_criticality', 'type' => 'tinyint', 'unsigned' => true, 'NULL' => false, 'default' => '0']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_warn', 'type' => 'double', 'NULL' => false, 'default' => '0']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_alert', 'type' => 'double', 'NULL' => false, 'default' => '0']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_icon', 'type' => 'varchar(30)', 'NULL' => false, 'default' => '']); + + db_execute('SET SESSION innodb_strict_mode=1'); +} + +/** + * Refreshes this plugin's own tables to their current definition on + * upgrade: db_update_table() diffs the live schema against the definition + * and issues the exact ALTER when the table already exists, otherwise the + * table is created outright. Called from monitor_check_upgrade(). + * + * @return void + */ +function monitor_upgrade_tables() { + $tables = [ + 'plugin_monitor_notify_history' => monitor_notify_history_table_data(), + 'plugin_monitor_reboot_history' => monitor_reboot_history_table_data(), + 'plugin_monitor_uptime' => monitor_uptime_table_data(), + 'plugin_monitor_dashboards' => monitor_dashboards_table_data(), + ]; + + foreach ($tables as $table => $table_data) { + if (db_table_exists($table)) { + db_update_table($table, $table_data); + } else { + api_plugin_db_table_create('monitor', $table, $table_data); + } + } + + monitor_disown_dashboards_table(); +} + +/** + * Drops the tables this plugin owns that hold transient monitoring state + * (notification/reboot history and uptime). The user-defined dashboards + * table is intentionally left in place. Called from + * plugin_monitor_uninstall(). + * + * @return void + */ +function monitor_drop_tables() { + db_execute('DROP TABLE IF EXISTS plugin_monitor_notify_history'); + db_execute('DROP TABLE IF EXISTS plugin_monitor_reboot_history'); + db_execute('DROP TABLE IF EXISTS plugin_monitor_uptime'); +} diff --git a/db_functions.php b/includes/functions.php similarity index 100% rename from db_functions.php rename to includes/functions.php diff --git a/monitor_render.php b/includes/render.php similarity index 100% rename from monitor_render.php rename to includes/render.php diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index d66b5ee..ee1c27c 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-09-28 16:06-0400\n" +"POT-Creation-Date: 2026-09-30 21:27-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -17,615 +17,615 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: monitor.php poller_functions.php setup.php -msgid "Disabled" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "Low" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "Medium" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "High" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "Mission Critical" -msgstr "" - -#: monitor.php monitor_render.php -msgid "Unknown" -msgstr "" - -#: monitor.php -msgid "Down" -msgstr "" - -#: monitor.php -msgid "Recovering" -msgstr "" - -#: monitor.php -msgid "Up" -msgstr "" - -#: monitor.php -msgid "Triggered" -msgstr "" - -#: monitor.php -msgid "Triggered (Muted/Acked)" -msgstr "" - -#: monitor.php -msgid "Down (Muted/Acked)" -msgstr "" - -#: monitor.php -msgid "No Availability Check" -msgstr "" - -#: monitor.php -msgid "Warning Ping" +#: includes/controller.php +msgid "New Dashboard" msgstr "" -#: monitor.php -msgid "Alert Ping" +#: includes/controller.php +msgid "Dashboard" msgstr "" -#: monitor.php -msgid "Extra Small" +#: includes/controller.php +msgid "Enter the Dashboard Name and then press 'Save' to continue, else press 'Cancel'" msgstr "" -#: monitor.php -msgid "Small" +#: includes/controller.php +msgid "Create New Dashboard" msgstr "" -#: monitor.php -msgid "Large" +#: includes/controller.php includes/render.php +msgid "Monitored Devices" msgstr "" -#: monitor.php -msgid "Extra Large" +#: includes/controller.php monitor.php +msgid "Devices without Thresholds" msgstr "" -#: monitor.php -msgid "Zoom" +#: includes/controller.php +msgid "Not Monitored Devices" msgstr "" -#: monitor.php monitor_controller.php +#: includes/controller.php monitor.php msgid "All Devices" msgstr "" -#: monitor.php monitor_controller.php +#: includes/controller.php monitor.php msgid "All Monitored Devices" msgstr "" -#: monitor.php -msgid "Not Up" -msgstr "" - -#: monitor.php -msgid "Not Up or Triggered" -msgstr "" - -#: monitor.php -msgid "Not Up, Triggered or Breached" -msgstr "" - -#: monitor.php monitor_controller.php -msgid "Devices without Thresholds" -msgstr "" - -#: monitor.php -msgid "Devices not Monitored" -msgstr "" - -#: monitor.php setup.php -msgid "Default" -msgstr "" - -#: monitor.php setup.php -msgid "List" -msgstr "" - -#: monitor.php setup.php -msgid "Names only" -msgstr "" - -#: monitor.php setup.php -msgid "Tiles" -msgstr "" - -#: monitor.php -msgid "Tiles & Time" -msgstr "" - -#: monitor.php monitor_controller.php setup.php -msgid "Tree" -msgstr "" - -#: monitor.php monitor_render.php -msgid "Site" -msgstr "" - -#: monitor.php setup.php -msgid "Device Template" -msgstr "" - -#: monitor.php -msgid "Full" -msgstr "" - -#: monitor.php -msgid "10 Chars" -msgstr "" - -#: monitor.php -msgid "20 Chars" -msgstr "" - -#: monitor.php -msgid "30 Chars" -msgstr "" - -#: monitor.php -msgid "40 Chars" -msgstr "" - -#: monitor.php -msgid "50 Chars" -msgstr "" - -#: monitor.php -msgid "75 Chars" -msgstr "" - -#: monitor.php -msgid "100 Chars" -msgstr "" - -#: monitor_controller.php -msgid "New Dashboard" -msgstr "" - -#: monitor_controller.php -msgid "Dashboard" -msgstr "" - -#: monitor_controller.php -msgid "Enter the Dashboard Name and then press 'Save' to continue, else press 'Cancel'" -msgstr "" - -#: monitor_controller.php -msgid "Create New Dashboard" -msgstr "" - -#: monitor_controller.php monitor_render.php -msgid "Monitored Devices" -msgstr "" - -#: monitor_controller.php -msgid "Not Monitored Devices" -msgstr "" - -#: monitor_controller.php +#: includes/controller.php msgid "Monitored Devices either Down or Recovering" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Monitored Devices either Down, Recovering, or with Triggered Thresholds" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Monitored Devices either Down, Recovering, or with Breached or Triggered Thresholds" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Unknown monitoring status (%s)" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and All Criticalities" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of Low Criticality or Higher" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of Medium Criticality or Higher" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of High Criticality or Higher" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of Mission Critical Status" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "
Remember to first select eligible Devices to be Monitored from the Devices page!
" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Unsaved" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Layout" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Status" msgstr "" -#: monitor_controller.php setup.php +#: includes/controller.php setup.php msgid "View" msgstr "" -#: monitor_controller.php setup.php +#: includes/controller.php setup.php msgid "Grouping" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Refresh" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Refresh the Device List" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Clear" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Clear the set filter" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Save" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Save Filter Settings" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "New" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Save New Dashboard" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Rename" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Rename Dashboard" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Delete" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Delete Dashboard" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "%s Alert for downed Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "%s Alerts for downed Devices" msgstr "" -#: monitor_controller.php monitor_render.php poller_functions.php setup.php +#: includes/controller.php includes/render.php poller_functions.php setup.php msgid "Criticality" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Size" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Trim" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "All Trees" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Non-Tree Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php monitor.php setup.php +msgid "Tree" +msgstr "" + +#: includes/controller.php msgid "All Sites" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Non-Site Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Sites" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "All Templates" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Non-Templated Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Template" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid " [ Filter Settings Saved ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Cancel" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid " [ Refresh Again in %d Seconds ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Monitor Filter [ Last Refresh: %s ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "[ Showing only first 30 Devices ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Search" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Mute" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Acknowledge" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Un-Mute" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Reset" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' Removed." msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' is not owned by you." msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' has been Saved!" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' could not be Saved!" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Edit Device" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Graphs" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Thresholds/Alerts" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Device Syslog Alerts" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Device Syslog Entries" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Device Status Information" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Device:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Site:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Location:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Criticality:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Status:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Admin Note:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "IP/Hostname:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Curr/Avg:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php #, php-format msgid "%d ms" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Warn/Alert:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php #, php-format msgid "%0.2d ms" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Last Fail:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Time In State:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Availability:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Agent Uptime:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "N/A" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Sys Description:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Contact:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Notes:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Never" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Unspecified" msgstr "" -#: monitor_controller.php setup.php +#: includes/controller.php setup.php msgid "None" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Non-Site Device" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Root Branch" msgstr "" -#: monitor_render.php +#: includes/render.php #, php-format msgid "Tree: %s" msgstr "" -#: monitor_render.php +#: includes/render.php #, php-format msgid "Branch: %s" msgstr "" -#: monitor_render.php +#: includes/render.php monitor.php +msgid "Unknown" +msgstr "" + +#: includes/render.php msgid "Just Up" msgstr "" -#: monitor_render.php poller_functions.php +#: includes/render.php poller_functions.php msgid "Hostname" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Hostname of device" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "ID" msgstr "" -#: monitor_render.php poller_functions.php +#: includes/render.php poller_functions.php msgid "Description" msgstr "" -#: monitor_render.php +#: includes/render.php monitor.php +msgid "Site" +msgstr "" + +#: includes/render.php msgid "Length in Status" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Averages" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Warning" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Admin" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Monitor Text Column represents 'Admin'" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Notes" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Availability" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Last Fail" msgstr "" +#: monitor.php poller_functions.php setup.php +msgid "Disabled" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "Low" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "Medium" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "High" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "Mission Critical" +msgstr "" + +#: monitor.php +msgid "Down" +msgstr "" + +#: monitor.php +msgid "Recovering" +msgstr "" + +#: monitor.php +msgid "Up" +msgstr "" + +#: monitor.php +msgid "Triggered" +msgstr "" + +#: monitor.php +msgid "Triggered (Muted/Acked)" +msgstr "" + +#: monitor.php +msgid "Down (Muted/Acked)" +msgstr "" + +#: monitor.php +msgid "No Availability Check" +msgstr "" + +#: monitor.php +msgid "Warning Ping" +msgstr "" + +#: monitor.php +msgid "Alert Ping" +msgstr "" + +#: monitor.php +msgid "Extra Small" +msgstr "" + +#: monitor.php +msgid "Small" +msgstr "" + +#: monitor.php +msgid "Large" +msgstr "" + +#: monitor.php +msgid "Extra Large" +msgstr "" + +#: monitor.php +msgid "Zoom" +msgstr "" + +#: monitor.php +msgid "Not Up" +msgstr "" + +#: monitor.php +msgid "Not Up or Triggered" +msgstr "" + +#: monitor.php +msgid "Not Up, Triggered or Breached" +msgstr "" + +#: monitor.php +msgid "Devices not Monitored" +msgstr "" + +#: monitor.php setup.php +msgid "Default" +msgstr "" + +#: monitor.php setup.php +msgid "List" +msgstr "" + +#: monitor.php setup.php +msgid "Names only" +msgstr "" + +#: monitor.php setup.php +msgid "Tiles" +msgstr "" + +#: monitor.php +msgid "Tiles & Time" +msgstr "" + +#: monitor.php setup.php +msgid "Device Template" +msgstr "" + +#: monitor.php +msgid "Full" +msgstr "" + +#: monitor.php +msgid "10 Chars" +msgstr "" + +#: monitor.php +msgid "20 Chars" +msgstr "" + +#: monitor.php +msgid "30 Chars" +msgstr "" + +#: monitor.php +msgid "40 Chars" +msgstr "" + +#: monitor.php +msgid "50 Chars" +msgstr "" + +#: monitor.php +msgid "75 Chars" +msgstr "" + +#: monitor.php +msgid "100 Chars" +msgstr "" + #: poller_functions.php msgid "Description: " msgstr "" diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0957674 --- /dev/null +++ b/manifest.json @@ -0,0 +1,29 @@ +{ + "tombstones": [ + "db_functions.php", + "monitor_controller.php", + "monitor_render.php", + "sonar-project.properties", + "themes/" + ], + "expected": [ + "CHANGELOG.md", + "INFO", + "LICENSE", + "README.md", + "css/", + "docs/", + "images/", + "includes/", + "index.php", + "js/", + "locales/", + "manifest.json", + "monitor.php", + "poller_functions.php", + "poller_monitor.php", + "setup.php", + "sounds/" + ], + "whitelist": [] +} diff --git a/monitor.php b/monitor.php index e0bd462..956e96a 100644 --- a/monitor.php +++ b/monitor.php @@ -26,7 +26,7 @@ $guest_account = true; chdir('../../'); -include_once('./include/auth.php'); +require_once('./include/auth.php'); set_default_action(); @@ -126,9 +126,9 @@ $_SESSION['monitor_muted_hosts'] = []; } -include_once __DIR__ . '/db_functions.php'; -include_once __DIR__ . '/monitor_render.php'; -include_once __DIR__ . '/monitor_controller.php'; +include_once __DIR__ . '/includes/functions.php'; +include_once __DIR__ . '/includes/render.php'; +include_once __DIR__ . '/includes/controller.php'; validateRequestVars(); diff --git a/phpunit.xml b/phpunit.xml index 6cf36a5..e07992d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,7 +28,7 @@ setup.php - db_functions.php + includes/functions.php poller_functions.php diff --git a/setup.php b/setup.php index 0037827..a9611db 100644 --- a/setup.php +++ b/setup.php @@ -50,6 +50,10 @@ function plugin_monitor_csp_nonce(): string { * @return void */ function plugin_monitor_install() { + global $config; + + require_once($config['base_path'] . '/plugins/monitor/includes/database.php'); + // core plugin functionality api_plugin_register_hook('monitor', 'top_header_tabs', 'monitor_show_tab', 'setup.php'); api_plugin_register_hook('monitor', 'top_graph_header_tabs', 'monitor_show_tab', 'setup.php'); @@ -232,9 +236,11 @@ function monitor_device_table_bottom() { * @return void */ function plugin_monitor_uninstall() { - db_execute('DROP TABLE IF EXISTS plugin_monitor_notify_history'); - db_execute('DROP TABLE IF EXISTS plugin_monitor_reboot_history'); - db_execute('DROP TABLE IF EXISTS plugin_monitor_uptime'); + global $config; + + require_once($config['base_path'] . '/plugins/monitor/includes/database.php'); + + monitor_drop_tables(); } /** @@ -253,8 +259,8 @@ function plugin_monitor_page_head() { print get_md5_include_css('plugins/monitor/css/monitor.css') . PHP_EOL; - if (file_exists($config['base_path'] . '/plugins/monitor/themes/' . get_selected_theme() . '/monitor.css')) { - print get_md5_include_css('plugins/monitor/themes/' . get_selected_theme() . '/monitor.css') . PHP_EOL; + if (file_exists($config['base_path'] . '/plugins/monitor/css/' . get_selected_theme() . '.css')) { + print get_md5_include_css('plugins/monitor/css/' . get_selected_theme() . '.css') . PHP_EOL; } } @@ -274,7 +280,7 @@ function plugin_monitor_check_config() { // Here we will check to ensure everything is configured monitor_check_upgrade(); - include_once($config['library_path'] . '/database.php'); + require_once($config['library_path'] . '/database.php'); $r = read_config_option('monitor_refresh'); if ($r == '' || $r < 1 || $r > 300) { @@ -315,20 +321,22 @@ function monitor_check_upgrade() { return; } + global $config; + + require_once($config['base_path'] . '/plugins/monitor/includes/database.php'); + $info = plugin_monitor_version(); $current = $info['version']; $old = db_fetch_cell('SELECT version FROM plugin_config WHERE directory = "monitor"'); if ($current != $old) { monitor_setup_table(); + monitor_upgrade_tables(); api_plugin_register_hook('monitor', 'page_head', 'plugin_monitor_page_head', 'setup.php', 1); db_execute('ALTER TABLE host MODIFY COLUMN monitor char(3) DEFAULT "on"'); - db_execute('ALTER TABLE plugin_monitor_uptime - MODIFY COLUMN uptime BIGINT unsigned NOT NULL default "0"'); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_icon', 'type' => 'varchar(30)', 'NULL' => false, 'default' => '']); if (function_exists('api_plugin_upgrade_register')) { @@ -346,6 +354,9 @@ function monitor_check_upgrade() { ] ); } + + // Remove files tombstoned in manifest.json plus the dev-only tests/ tree. + monitor_prune_files(); } } @@ -1374,115 +1385,201 @@ function monitor_draw_navigation_text($nav) { } /** - * Creates (if not already present) all of this plugin's database - * tables (notify/reboot history, uptime, dashboards), ensures the host - * table uses DYNAMIC row format (needed for its added TEXT column), - * and adds this plugin's monitoring columns to the host table. Called - * from plugin_monitor_install() and monitor_check_upgrade(). + * Poller_bottom hook: on the main poller (poller_id 1), launches this + * plugin's poller_monitor.php script as a background process at the + * end of each Cacti polling cycle. Called by Cacti's poller via the + * 'poller_bottom' hook. * * @return void + * + * @global array $config Cacti global configuration array; used to + * check the poller id and locate the PHP binary + * and this plugin's poller script. */ -function monitor_setup_table() { - if (!db_table_exists('plugin_monitor_notify_history')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_notify_history ( - id int(10) unsigned NOT NULL AUTO_INCREMENT, - host_id int(10) unsigned DEFAULT NULL, - notify_type tinyint(3) unsigned DEFAULT NULL, - ping_time double DEFAULT NULL, - ping_threshold int(10) unsigned DEFAULT NULL, - notification_time timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - notes varchar(255) DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY unique_key (host_id,notify_type,notification_time)) - ENGINE=InnoDB - COMMENT='Stores Notification Event History'"); +function monitor_poller_bottom() { + global $config; + + if ($config['poller_id'] == 1) { + require_once($config['library_path'] . '/poller.php'); + + $command_string = trim(read_config_option('path_php_binary')); + + if (trim($command_string) == '') { + $command_string = 'php'; + } + + $extra_args = ' -q ' . $config['base_path'] . '/plugins/monitor/poller_monitor.php'; + + exec_background($command_string, $extra_args); } +} + +/** + * Removes files and directories that a previous version of this plugin + * shipped but that have since moved or been deleted, using the tombstone + * and whitelist lists in manifest.json. Whitelisted (user-data) paths and + * any VCS metadata (.git*) are never touched; the dev-only tests/ tree is + * removed. Any path that resolves outside the plugin directory (a tampered + * manifest.json) is refused, and any file/directory that cannot be removed + * (e.g. read-only) is reported to the Cacti log. Any top-level entry that is + * neither expected nor a tombstone nor whitelisted is logged to the Cacti + * log and left in place. Called on a plugin version change. + * + * @return void + * + * @global array $config Cacti global configuration array; used to resolve + * the plugin directory. + */ +function monitor_prune_files(): void { + global $config; + + $plugin_dir = $config['base_path'] . '/plugins/monitor'; + $manifest_path = $plugin_dir . '/manifest.json'; - if (!db_table_exists('plugin_monitor_reboot_history')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_reboot_history ( - id int(10) unsigned NOT NULL AUTO_INCREMENT, - host_id int(10) unsigned DEFAULT NULL, - reboot_time timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - log_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - KEY host_id (host_id), - KEY log_time (log_time), - KEY reboot_time (reboot_time)) - ENGINE=InnoDB - COMMENT='Keeps Track of Device Reboot Times'"); + if (!is_readable($manifest_path)) { + return; } - if (!db_table_exists('plugin_monitor_uptime')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_uptime ( - host_id int(10) unsigned DEFAULT '0', - uptime bigint(20) unsigned DEFAULT '0', - PRIMARY KEY (host_id), - KEY uptime (uptime)) - ENGINE=InnoDB - COMMENT='Keeps Track of the Devices last uptime to track agent restarts and reboots'"); + $manifest = json_decode((string) file_get_contents($manifest_path), true); + + if (!is_array($manifest)) { + cacti_log('WARNING: monitor manifest.json could not be parsed; skipping file prune', false, 'MONITOR'); + + return; } - if (!db_table_exists('plugin_monitor_dashboards')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_dashboards ( - id int(10) unsigned auto_increment, - user_id int(10) unsigned DEFAULT '0', - name varchar(128) DEFAULT '', - url varchar(1024) DEFAULT '', - PRIMARY KEY (id), - KEY user_id (user_id)) - ENGINE=InnoDB - COMMENT='Stores predefined dashboard information for a user or users'"); + $tombstones = isset($manifest['tombstones']) && is_array($manifest['tombstones']) ? $manifest['tombstones'] : []; + $expected = isset($manifest['expected']) && is_array($manifest['expected']) ? $manifest['expected'] : []; + $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; + + $protected = function (string $rel) use ($whitelist): bool { + if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) { + return true; + } + + foreach ($whitelist as $entry) { + $entry = trim((string) $entry, '/'); + + if ($entry !== '' && ($rel === $entry + || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0 + || strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) { + return true; + } + } + + return false; + }; + + // Security: resolve the plugin directory so a tampered manifest.json + // cannot steer the prune outside of it. + $plugin_real = realpath($plugin_dir); + + // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ + // tree and the phpunit.xml test configuration. + $remove = $tombstones; + $remove[] = 'tests/'; + $remove[] = 'phpunit.xml'; + + foreach ($remove as $rel) { + $rel = trim((string) $rel, '/'); + + if ($rel === '' || $protected($rel)) { + continue; + } + + // A tombstone must never contain '.'/'..' segments; a tampered manifest + // could use them to escape the plugin directory or target its root. + $segments = explode('/', $rel); + + if (in_array('.', $segments, true) || in_array('..', $segments, true)) { + cacti_log(sprintf('WARNING: monitor prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'MONITOR'); + + continue; + } + + $path = $plugin_dir . '/' . $rel; + + if (!is_link($path) && !file_exists($path)) { + continue; + } + + // Refuse any path that, after resolving symlinks and ../ segments, + // escapes the plugin directory (protects user data from a tampered + // manifest.json). + $anchor = is_link($path) ? dirname($path) : $path; + $real = realpath($anchor); + + if ($real === false || ($real !== $plugin_real && strncmp($real, $plugin_real . DIRECTORY_SEPARATOR, strlen((string) $plugin_real) + 1) !== 0)) { + cacti_log(sprintf('WARNING: monitor prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'MONITOR'); + + continue; + } + + if (is_dir($path) && !is_link($path)) { + $removed = monitor_rmtree($path); + } else { + $removed = @unlink($path); + } + + if (!$removed) { + cacti_log(sprintf('WARNING: monitor upgrade could not remove %s (check file/directory permissions)', $rel), false, 'MONITOR'); + } } - if (db_table_exists('host')) { - $row_format = db_fetch_cell("SELECT ROW_FORMAT - FROM information_schema.tables - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'host'"); + // Surface any top-level entry the manifest does not account for. + $known = []; - if (strtoupper((string) $row_format) !== 'DYNAMIC') { - db_execute('ALTER TABLE host ROW_FORMAT=DYNAMIC'); + foreach (array_merge($expected, $tombstones) as $entry) { + $top = explode('/', trim((string) $entry, '/'))[0]; + + if ($top !== '') { + $known[$top] = true; } } - db_execute('SET SESSION innodb_strict_mode=0'); + $entries = scandir($plugin_dir); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor', 'type' => 'char(3)', 'NULL' => true, 'default' => 'on']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_text', 'type' => 'text', 'NULL' => false]); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_criticality', 'type' => 'tinyint', 'unsigned' => true, 'NULL' => false, 'default' => '0']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_warn', 'type' => 'double', 'NULL' => false, 'default' => '0']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_alert', 'type' => 'double', 'NULL' => false, 'default' => '0']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_icon', 'type' => 'varchar(30)', 'NULL' => false, 'default' => '']); + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) { + continue; + } - db_execute('SET SESSION innodb_strict_mode=1'); + cacti_log(sprintf('WARNING: monitor upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'MONITOR'); + } } /** - * Poller_bottom hook: on the main poller (poller_id 1), launches this - * plugin's poller_monitor.php script as a background process at the - * end of each Cacti polling cycle. Called by Cacti's poller via the - * 'poller_bottom' hook. + * Recursively deletes a directory and its contents. Symlinks are removed + * without being followed. Helper for monitor_prune_files(). * - * @return void + * @param string $dir Absolute path to the directory to remove. * - * @global array $config Cacti global configuration array; used to - * check the poller id and locate the PHP binary - * and this plugin's poller script. + * @return bool True if the directory and everything under it was removed; + * false if any entry could not be deleted. */ -function monitor_poller_bottom() { - global $config; +function monitor_rmtree(string $dir): bool { + $entries = scandir($dir); + $ok = true; - if ($config['poller_id'] == 1) { - include_once($config['library_path'] . '/poller.php'); + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } - $command_string = trim(read_config_option('path_php_binary')); + $path = $dir . '/' . $entry; - if (trim($command_string) == '') { - $command_string = 'php'; + if (is_dir($path) && !is_link($path)) { + if (!monitor_rmtree($path)) { + $ok = false; + } + } elseif (!@unlink($path)) { + $ok = false; } + } - $extra_args = ' -q ' . $config['base_path'] . '/plugins/monitor/poller_monitor.php'; - - exec_background($command_string, $extra_args); + if (!@rmdir($dir)) { + $ok = false; } + + return $ok; } diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 5ed8db9..0000000 --- a/sonar-project.properties +++ /dev/null @@ -1,10 +0,0 @@ -sonar.projectKey=plugin_monitor -sonar.sources=. -sonar.exclusions=vendor/** - -# php:S4833 - "Use namespaces instead of include/require" -# This is a Cacti plugin with a required procedural architecture. -# Namespaces cannot be used; include_once is the correct and only mechanism. -sonar.issue.ignore.multicriteria=e1 -sonar.issue.ignore.multicriteria.e1.ruleKey=php:S4833 -sonar.issue.ignore.multicriteria.e1.resourceKey=**/*.php diff --git a/tests/Integration/MonitorRequestOutputWiringTest.php b/tests/Integration/MonitorRequestOutputWiringTest.php index eacc2e8..bf19036 100644 --- a/tests/Integration/MonitorRequestOutputWiringTest.php +++ b/tests/Integration/MonitorRequestOutputWiringTest.php @@ -8,14 +8,14 @@ */ dataset('output_hardening', [ - 'controller downhosts' => ['monitor_controller.php', "html_escape(get_request_var('downhosts'))"], - 'controller mute' => ['monitor_controller.php', "html_escape(get_request_var('mute'))"], - 'controller tree' => ['monitor_controller.php', "html_escape(get_request_var('tree'))"], - 'controller site' => ['monitor_controller.php', "html_escape(get_request_var('site'))"], - 'controller template' => ['monitor_controller.php', "html_escape(get_request_var('template'))"], - 'controller size' => ['monitor_controller.php', "html_escape(get_request_var('size'))"], - 'controller trim' => ['monitor_controller.php', "html_escape(get_request_var('trim'))"], - 'render rfilter' => ['monitor_render.php', "rawurlencode(get_request_var('rfilter'))"], + 'controller downhosts' => ['includes/controller.php', "html_escape(get_request_var('downhosts'))"], + 'controller mute' => ['includes/controller.php', "html_escape(get_request_var('mute'))"], + 'controller tree' => ['includes/controller.php', "html_escape(get_request_var('tree'))"], + 'controller site' => ['includes/controller.php', "html_escape(get_request_var('site'))"], + 'controller template' => ['includes/controller.php', "html_escape(get_request_var('template'))"], + 'controller size' => ['includes/controller.php', "html_escape(get_request_var('size'))"], + 'controller trim' => ['includes/controller.php', "html_escape(get_request_var('trim'))"], + 'render rfilter' => ['includes/render.php', "rawurlencode(get_request_var('rfilter'))"], ]); it('keeps expected output hardening in place', function (string $file, string $pattern) { diff --git a/tests/Integration/OutputEscapingTest.php b/tests/Integration/OutputEscapingTest.php index 81932e8..2cb6818 100644 --- a/tests/Integration/OutputEscapingTest.php +++ b/tests/Integration/OutputEscapingTest.php @@ -16,14 +16,14 @@ $root = dirname(__DIR__, 2); dataset('escaped_outputs', [ - ['monitor_controller.php', "html_escape(get_request_var('downhosts'))"], - ['monitor_controller.php', "html_escape(get_request_var('mute'))"], - ['monitor_controller.php', "html_escape(get_request_var('tree'))"], - ['monitor_controller.php', "html_escape(get_request_var('site'))"], - ['monitor_controller.php', "html_escape(get_request_var('template'))"], - ['monitor_controller.php', "html_escape(get_request_var('size'))"], - ['monitor_controller.php', "html_escape(get_request_var('trim'))"], - ['monitor_render.php', "rawurlencode(get_request_var('rfilter'))"], + ['includes/controller.php', "html_escape(get_request_var('downhosts'))"], + ['includes/controller.php', "html_escape(get_request_var('mute'))"], + ['includes/controller.php', "html_escape(get_request_var('tree'))"], + ['includes/controller.php', "html_escape(get_request_var('site'))"], + ['includes/controller.php', "html_escape(get_request_var('template'))"], + ['includes/controller.php', "html_escape(get_request_var('size'))"], + ['includes/controller.php', "html_escape(get_request_var('trim'))"], + ['includes/render.php', "rawurlencode(get_request_var('rfilter'))"], ]); it('escapes request values before printing them', function (string $file, string $pattern) use ($root) { @@ -31,13 +31,13 @@ })->with('escaped_outputs'); dataset('raw_reuse', [ - ['monitor_controller.php', "get_request_var('downhosts') . '\">'"], - ['monitor_controller.php', "get_request_var('site') . '\">'"], - ['monitor_controller.php', "get_request_var('template') . '\">'"], - ['monitor_controller.php', "get_request_var('size') . '\">'"], - ['monitor_controller.php', "get_request_var('trim') . '\">'"], - ['monitor_render.php', "monitor.php?rfilter=' . get_request_var('rfilter')"], + ['includes/controller.php', "get_request_var('downhosts') . '\">'"], + ['includes/controller.php', "get_request_var('site') . '\">'"], + ['includes/controller.php', "get_request_var('template') . '\">'"], + ['includes/controller.php', "get_request_var('size') . '\">'"], + ['includes/controller.php', "get_request_var('trim') . '\">'"], + ['includes/render.php', "monitor.php?rfilter=' . get_request_var('rfilter')"], ]); it('never concatenates a raw request value into markup', function (string $file, string $pattern) use ($root) { diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index 3f69cd0..f60b799 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -3,13 +3,13 @@ +-------------------------------------------------------------------------+ | Copyright (C) 2004-2026 The Cacti Group | +-------------------------------------------------------------------------+ - | Exercises db_functions.php through the Cacti stubs in bootstrap-unit.php.| + | Exercises includes/functions.php through the Cacti stubs in bootstrap-unit.php.| | These load and run plugin code, unlike the source-matching rules in | | tests/Integration. | +-------------------------------------------------------------------------+ */ -require_once dirname(__DIR__, 2) . '/db_functions.php'; +require_once dirname(__DIR__, 2) . '/includes/functions.php'; it('selects the breached threshold clause for status 2', function () { test_set_request(['status' => '2']); diff --git a/tests/Unit/MonitorLifecycleTest.php b/tests/Unit/MonitorLifecycleTest.php index dcf368c..73f1f53 100644 --- a/tests/Unit/MonitorLifecycleTest.php +++ b/tests/Unit/MonitorLifecycleTest.php @@ -18,6 +18,10 @@ beforeAll(function () { require_once __DIR__ . '/../../setup.php'; + // Define the schema functions (monitor_setup_table, monitor_upgrade_tables) + // from the real checkout so monitor_check_upgrade() can run while base_path + // is sandboxed for the upgrade-time prune. + require_once dirname(__DIR__, 2) . '/includes/database.php'; $stubLibraryPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'monitor-test-lib-stub'; @@ -36,6 +40,22 @@ $GLOBALS['__test_db_calls'] = array(); $GLOBALS['__test_config_options'] = array(); $_SERVER['PHP_SELF'] = '/monitor.php'; + + // Sandbox base_path (with a minimal INFO + empty includes/database.php stub) + // so any upgrade-path test runs monitor_prune_files() against a + // throwaway tree, never the real checkout. + $GLOBALS['__monitor_base_restore'] = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/monitor-test-' . uniqid(); + mkdir($base . '/plugins/monitor/includes', 0777, true); + file_put_contents($base . '/plugins/monitor/INFO', "[info]\nversion = 9.9.9\nname = monitor\nlongname = Monitor\nauthor = x\nhomepage = x\n"); + file_put_contents($base . '/plugins/monitor/includes/database.php', "toContain('ALTER TABLE host'); }); + +it('launches the poller with a php fallback when no binary is configured', function () { + $GLOBALS['config']['poller_id'] = 1; + $GLOBALS['__test_exec_calls'] = array(); + + monitor_poller_bottom(); + + expect($GLOBALS['__test_exec_calls'])->not->toBeEmpty(); + expect($GLOBALS['__test_exec_calls'][0]['command'])->toBe('php'); +}); diff --git a/tests/Unit/MonitorPageHeadTest.php b/tests/Unit/MonitorPageHeadTest.php new file mode 100644 index 0000000..f35aa3c --- /dev/null +++ b/tests/Unit/MonitorPageHeadTest.php @@ -0,0 +1,58 @@ +.css. + */ + +beforeAll(function () { + require_once __DIR__ . '/../../setup.php'; +}); + +afterEach(function () { + unset($GLOBALS['__test_selected_theme']); +}); + +it('links the per-theme stylesheet from css/ when it exists', function () { + $restore = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/monitor-ph-' . uniqid(); + mkdir($base . '/plugins/monitor/css', 0777, true); + file_put_contents($base . '/plugins/monitor/css/modern.css', ''); + $GLOBALS['__test_selected_theme'] = 'modern'; + $GLOBALS['config']['base_path'] = $base; + + ob_start(); + + try { + plugin_monitor_page_head(); + } finally { + $output = ob_get_clean(); + $GLOBALS['config']['base_path'] = $restore; + } + + expect($output)->toContain('plugins/monitor/css/modern.css'); +}); + +it('emits only the base stylesheet when no per-theme override exists', function () { + $restore = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/monitor-ph-' . uniqid(); + mkdir($base . '/plugins/monitor/css', 0777, true); + $GLOBALS['__test_selected_theme'] = 'no-such-theme'; + $GLOBALS['config']['base_path'] = $base; + + ob_start(); + + try { + plugin_monitor_page_head(); + } finally { + $output = ob_get_clean(); + $GLOBALS['config']['base_path'] = $restore; + } + + expect($output)->toContain('plugins/monitor/css/monitor.css'); +}); diff --git a/tests/Unit/MonitorSetupTableAndPollerTest.php b/tests/Unit/MonitorSetupTableAndPollerTest.php index d4569d4..9852b3a 100644 --- a/tests/Unit/MonitorSetupTableAndPollerTest.php +++ b/tests/Unit/MonitorSetupTableAndPollerTest.php @@ -17,6 +17,7 @@ beforeAll(function () { require_once __DIR__ . '/../../setup.php'; + require_once __DIR__ . '/../../includes/database.php'; $stubLibraryPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'monitor-test-lib-stub'; @@ -47,6 +48,18 @@ } }); +it('does not keep plugin ownership of the user-owned dashboards table', function () { + monitor_setup_table(); + + $disowned = array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute' + && stripos($call['sql'], 'DELETE FROM plugin_db_changes') !== false + && stripos($call['sql'], 'plugin_monitor_dashboards') !== false; + }); + + expect($disowned)->not->toBeEmpty(); +}); + it('launches poller_monitor.php in the background on the primary poller', function () { $GLOBALS['__test_config_options']['path_php_binary'] = '/usr/bin/php'; diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php new file mode 100644 index 0000000..ca30cdd --- /dev/null +++ b/tests/Unit/PruneFilesTest.php @@ -0,0 +1,255 @@ + ['include/', 'oldfile.php', 'userdata/', 'gone.png'], + 'expected' => ['INFO', 'setup.php', 'includes/', 'manifest.json'], + 'whitelist' => ['userdata/'], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // Tombstone and the dev-only tests/ tree are gone. + expect(is_dir($plugin . '/include'))->toBeFalse(); + expect(is_dir($plugin . '/tests'))->toBeFalse(); + expect(is_file($plugin . '/oldfile.php'))->toBeFalse(); + expect(is_file($plugin . '/phpunit.xml'))->toBeFalse(); + + // Whitelisted user data, VCS metadata, and expected files are untouched. + // (userdata/ is even listed as a tombstone, but the whitelist wins.) + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); + expect(is_dir($plugin . '/.git'))->toBeTrue(); + expect(is_file($plugin . '/INFO'))->toBeTrue(); + expect(is_dir($plugin . '/includes'))->toBeTrue(); + expect(is_file($plugin . '/.mdlrc'))->toBeTrue(); + expect(is_file($plugin . '/.md_style.rb'))->toBeTrue(); + + // An unexpected, non-whitelisted stray is left in place but logged. + expect(is_file($plugin . '/stray.php'))->toBeTrue(); + + $logged = implode("\n", $GLOBALS['__test_cacti_log']); + expect($logged)->toContain('stray.php'); + expect($logged)->not->toContain('userdata'); + expect($logged)->not->toContain('.git'); + expect($logged)->not->toContain('.mdlrc'); + expect($logged)->not->toContain('.md_style.rb'); +}); + +it('is a safe no-op when the manifest is missing', function () { + $base = sys_get_temp_dir() . '/monitor-prune-missing-' . uniqid(); + mkdir($base . '/plugins/monitor', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + expect($GLOBALS['__test_cacti_log'])->toBe([]); +}); + +it('logs and skips pruning when the manifest is malformed', function () { + $base = sys_get_temp_dir() . '/monitor-prune-bad-' . uniqid(); + $plugin = $base . '/plugins/monitor'; + mkdir($plugin, 0777, true); + file_put_contents($plugin . '/manifest.json', 'not json'); + mkdir($plugin . '/tests', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A malformed manifest must not delete anything. + expect(is_dir($plugin . '/tests'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not be parsed'); +}); + +it('refuses to remove a tombstone that resolves outside the plugin directory', function () { + $manifest = [ + 'tombstones' => ['../escapee.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $outside = $base . '/plugins/escapee.txt'; + file_put_contents($outside, 'precious user data'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file is untouched and the refusal is logged. + expect(is_file($outside))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('a traversal segment'); +}); + +it('warns when a tombstoned path cannot be removed', function () { + $manifest = [ + 'tombstones' => ['locked/'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + mkdir($plugin . '/locked/sub', 0777, true); + file_put_contents($plugin . '/locked/sub/data', 'x'); + chmod($plugin . '/locked/sub', 0500); // read-only dir: its child cannot be unlinked + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + set_error_handler(static fn () => true); // swallow the expected unlink warning + + try { + monitor_prune_files(); + } finally { + restore_error_handler(); + $GLOBALS['config']['base_path'] = $restore; + @chmod($plugin . '/locked/sub', 0700); + } + + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not remove'); +})->skip(function () { + return function_exists('posix_getuid') && posix_getuid() === 0; +}, 'permission checks are bypassed for the root user'); + +it('refuses a tombstone that escapes through a symlinked directory', function () { + $manifest = [ + 'tombstones' => ['escdir/secret.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $outside = $base . '/outside'; + mkdir($outside, 0777, true); + file_put_contents($outside . '/secret.txt', 'precious user data'); + @symlink($outside, $plugin . '/escdir'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file reached through the symlink is untouched and logged. + expect(is_file($outside . '/secret.txt'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); +})->skip(function () { + $probe = sys_get_temp_dir() . '/.prune-symlink-probe-' . uniqid(); + $ok = @symlink(__FILE__, $probe); + @unlink($probe); + + return $ok === false; +}, 'symlinks are not supported on this filesystem'); + +it('protects a whitelisted file from a tombstone on its parent directory', function () { + $manifest = [ + 'tombstones' => ['userdata/'], + 'expected' => ['manifest.json'], + 'whitelist' => ['userdata/keep.dat'], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A whitelisted file shields its parent directory from a tombstone. + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); +}); diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index e65b53d..059d044 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -160,6 +160,9 @@ function changed_lines($base_ref) { * Empty by default; add entries per repository as the need arises. */ $unmeasured_allowlist = [ + 'includes/database.php', // install/upgrade/drop schema bootstrap; require_once's the live Cacti library, only runs during a real install/upgrade + 'monitor.php', // web UI entry point (chdir + require auth.php); not loadable in the isolated unit process + 'includes/controller.php', // request-controller library included by monitor.php after auth.php; not loadable in the isolated unit process ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php new file mode 100644 index 0000000..c54fab5 --- /dev/null +++ b/tests/bin/validate-manifest.php @@ -0,0 +1,73 @@ +"; - } -} - -if (!function_exists('get_md5_include_css')) { - function get_md5_include_css($path) { - return ""; - } -} - +if (!function_exists('get_md5_include_js')) { + function get_md5_include_js($path, $async = false) { + return ""; + } +} + +if (!function_exists('get_md5_include_css')) { + function get_md5_include_css($path) { + return ""; + } +} + +if (!function_exists('get_selected_theme')) { + function get_selected_theme() { + return $GLOBALS['__test_selected_theme'] ?? 'modern'; + } +} + if (!function_exists('monitor_test_mock_db')) { function monitor_test_mock_db($fn, $match, $result) { $GLOBALS['__test_db_fixtures'][] = array('fn' => $fn, 'match' => $match, 'result' => $result); @@ -209,6 +215,16 @@ function api_plugin_db_add_column($plugin, $table, $data) { if (!function_exists('api_plugin_db_table_create')) { function api_plugin_db_table_create($plugin, $table, $data) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'api_plugin_db_table_create', 'plugin' => $plugin, 'table' => $table, 'data' => $data, 'sql' => $table); + + return true; + } +} + +if (!function_exists('db_update_table')) { + function db_update_table($table, $data) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_update_table', 'table' => $table, 'data' => $data, 'sql' => $table); + return true; } } @@ -294,6 +310,8 @@ function __esc($text, $domain = '') { if (!function_exists('cacti_log')) { function cacti_log($message, $also_print = false, $log_type = '', $level = 0) { + $args = func_get_args(); + $GLOBALS["__test_cacti_log"][] = isset($args[0]) ? (string) $args[0] : ""; } } @@ -349,7 +367,7 @@ function test_set_request(array $vars): void { $GLOBALS['__test_request'] = $vars; } -// validateRequestVars() is not stubbed here: monitor_controller.php declares +// validateRequestVars() is not stubbed here: includes/controller.php declares // it unconditionally, so a global stub would fatal with a redeclare error // once a test loads that file via loadPluginSource()/monitor_test_load(). diff --git a/themes/dark/index.php b/themes/dark/index.php deleted file mode 100644 index 362957c..0000000 --- a/themes/dark/index.php +++ /dev/null @@ -1,26 +0,0 @@ -