diff --git a/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md index ec596aa4..d6a616f1 100644 --- a/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md +++ b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md @@ -34,21 +34,68 @@ User clicks button --- -## 2. Existing Database Fields (`OGP_DB_PREFIXaddons`) +## 2. Database Schema (db_version 4 — Phase 2) -| Column | Type | Description | -|---------------|-----------------|--------------------------------------------------| -| addon_id | INT UNSIGNED PK | Auto-increment primary key | -| name | VARCHAR(80) | Display name shown to users | -| url | VARCHAR(200) | Download URL (zip / tar.gz) | -| path | VARCHAR(80) | Relative target path inside server home | -| addon_type | VARCHAR(32)* | Content category key (plugin / mappack / config / …) | -| home_cfg_id | VARCHAR(7) | Linked game configuration ID | -| post_script | LONGTEXT | Bash script run by agent after install | -| group_id | INT(11) NULL | Restrict visibility to a specific user group | +### `OGP_DB_PREFIXaddons` (content item catalogue) -\* Expanded from VARCHAR(7) to VARCHAR(32) in db_version 2 - (migration runs automatically via the module update system). +| Column | Type | Default | Description | +|-----------------------|-----------------|---------------|-------------| +| addon_id | INT UNSIGNED PK | auto | Primary key | +| name | VARCHAR(80) | | Display name | +| url | VARCHAR(200) | | Download URL (zip / tar.gz / API endpoint) | +| path | VARCHAR(80) | | Relative target path inside server home | +| addon_type | VARCHAR(32) | | Category key (plugin / mappack / config / …) | +| home_cfg_id | VARCHAR(7) | | Linked game config ID | +| post_script | LONGTEXT | | Bash script run by agent after install | +| group_id | INT(11) NULL | | Restrict to a specific user group | +| install_method ¹ | VARCHAR(32) | download_zip | How the content is delivered | +| content_version ¹ | VARCHAR(64) NULL| | Version tag shown in manifest | +| requires_stop ¹ | TINYINT(1) | 1 | Must server be stopped before install? | +| backup_before_install¹| TINYINT(1) | 1 | Back up target path before installing? | +| restart_after_install¹| TINYINT(1) | 0 | Restart server after successful install? | +| is_cacheable ¹ | TINYINT(1) | 0 | Safe for cross-server copy / shared cache? | +| description ¹ | TEXT NULL | | Short description shown to users | + +¹ Added in db_version 4 (Phase 2). + +### `OGP_DB_PREFIXserver_content_manifest` (Phase 2 — per-server installed state) + +One row per (home_id, addon_id) pair. Updated on every successful install. + +| Column | Type | Description | +|-----------------|------------------|-------------| +| id | INT UNSIGNED PK | Auto primary key | +| home_id | INT | Server home | +| addon_id | INT | Installed content item | +| install_method | VARCHAR(32) | Method used for the install | +| content_version | VARCHAR(64) NULL | Version tag at time of install | +| install_state | VARCHAR(32) | installed / failed / removed | +| checksum_sha256 | VARCHAR(64) NULL | SHA-256 of the installed archive | +| source_url | VARCHAR(255) NULL| URL used for the install | +| installed_at | DATETIME | Timestamp of last successful install | +| installed_by | INT NULL | User ID who performed the install | +| updated_at | DATETIME NULL | Last update time | +| notes | TEXT NULL | Free-text admin notes | + +### `OGP_DB_PREFIXserver_content_install_history` (Phase 2 — install log) + +One row per install attempt. Never deleted (audit trail). + +| Column | Type | Description | +|-----------------|-------------------|-------------| +| id | INT UNSIGNED PK | Auto primary key | +| home_id | INT | Server home | +| addon_id | INT | Content item installed | +| installed_by | INT NULL | User who triggered the install | +| started_at | DATETIME | When the install was initiated | +| completed_at | DATETIME NULL | When the install completed | +| install_state | VARCHAR(32) | started / installed / failed / cancelled | +| install_method | VARCHAR(32) NULL | Method used | +| content_version | VARCHAR(64) NULL | Version tag | +| source_url | VARCHAR(255) NULL | Source URL used | +| cache_mode_used | VARCHAR(32) NULL | Value of server_content_cache_mode at install time | +| result_code | INT NULL | Script exit code (0 = success) | +| log_output | MEDIUMTEXT NULL | Script / download log excerpt | --- @@ -161,21 +208,38 @@ Apply this as `$install_queries[2]` (db_version 3) in `module.php` when ready. ## 9. Recommended Phased Migration Plan -### Phase 1 (complete — this PR) +### Phase 1 (complete) - [x] UI labels renamed to "Server Content Manager / Server Content". - [x] Central category map created (`server_content_categories.php`). - [x] `addon_type` column expanded to VARCHAR(32) via db_version 2 migration. - [x] `addons_installer.php` and `user_addons.php` use category map for validation. - [x] Full TODO/comment blocks added to installer for next phase work. - [x] Module folder, table names, URL routes, function names unchanged. +- [x] Workshop Content Phase 1: manual Workshop ID entry, per-server manifest, + agent script runner, install/update/remove actions (`db_version 3`). ### Phase 2 — Schema & install_method support -- [ ] Apply the `$install_queries[2]` schema above (db_version 3). -- [ ] Add `install_method` dropdown to admin create/edit form. -- [ ] Implement `requires_stop` check in installer before download. -- [ ] Implement `backup_before_install` using agent tar/zip helper. -- [ ] Implement `restart_after_install` using existing server start logic. -- [ ] Add install history table and log writes. +- [x] Apply Phase 2 schema: `install_method`, `content_version`, `requires_stop`, + `backup_before_install`, `restart_after_install`, `is_cacheable`, `description` + columns added to `addons` table via `$install_queries[3]` (db_version 4). +- [x] `server_content_manifest` table created – tracks one row per installed + content item per server home (version, checksum, state, installed_by). +- [x] `server_content_install_history` table created – one row per install + attempt with result code, log snippet, and cache_mode_used. +- [x] `install_method` dropdown added to admin create/edit form. +- [x] `content_version`, `description`, `is_cacheable` fields added to form. +- [x] `requires_stop` / `backup_before_install` / `restart_after_install` + checkboxes added to admin form. +- [x] `requires_stop` check implemented in installer: blocks install if server + is running (Phase 3 will add automatic stop/start). +- [x] Install history row written on `state=start`; completed on success. +- [x] Manifest row upserted on successful install. +- [x] `server_content_cache_mode` panel setting added (disabled / search_existing_servers / + shared_cache / shared_cache_and_search; default: **disabled**). +- [x] `is_cacheable` flag stored per content item; cache mode and is_cacheable + guard are in place — actual cross-server/cache copy logic is Phase 3. +- [ ] `backup_before_install` agent call (Phase 3 — needs agent tar/zip helper). +- [ ] `restart_after_install` agent call (Phase 3 — needs server start after install). ### Phase 3 — Steam Workshop integration See Part 6 below. @@ -295,3 +359,59 @@ Steam Workshop content is treated as a Server Content type (`addon_type=workshop 6. **Install history must be logged.** - Record who installed what, when, and the script exit code. - This log must be readable by admins but not modifiable by users. + +--- + +## 14. Part 10: Content Reuse / Cache Mode Security Rules + +### Governing setting: `server_content_cache_mode` + +| Value | Agent behaviour | +|--------------------------|-----------------| +| `disabled` (**default**) | Always install from the configured source/script. No cross-server copy, no shared cache. | +| `search_existing_servers` | Agent may scan other local game-server folders for matching **cacheable** content and copy directly if the checksum matches. | +| `shared_cache` | Agent may store **cacheable** content in a shared cache folder and use it for future installs. | +| `shared_cache_and_search` | Both `shared_cache` and `search_existing_servers` are active. | + +### Content eligibility for sharing / caching + +Only content where **`is_cacheable = 1`** on the addon record may ever be +shared or placed in the shared cache. Admins must explicitly opt in per item. + +**Never mark the following as cacheable:** +- Config files, server.cfg, whitelist / banlist, RCON passwords +- User-edited files, saves, player databases +- Log files, crash dumps +- Files containing credentials, tokens, or keys +- Entire server home directories + +**Safe to mark cacheable (public, non-sensitive, reproducible):** +- Publicly distributed map packs (.bsp, .nav, .vmf bundles) +- Workshop content / mod archives sourced from public URLs +- Vanilla server jars (Minecraft, etc.) from official APIs +- Stock config templates (not user-edited copies) + +### Checksum enforcement + +When a cached or copied file is used, the agent **must** verify the SHA-256 +checksum against the value stored in `server_content_manifest.checksum_sha256` +(if present). A checksum mismatch must abort the install and fall back to the +original source download. + +### Path restrictions + +- The agent must never copy files **outside** approved content paths. +- Users **cannot** specify source paths. Only admin-defined content items + define where content comes from and where it is placed. +- Path traversal (`../`) is forbidden in all `path` fields. The panel + validates this; the agent provides a second layer of defence. +- The shared cache folder must be separate from any game server home and not + accessible directly by game server processes. + +### Phase 3 implementation checklist (not yet done) + +- [ ] Agent-side: implement copy-from-existing-server with checksum verification. +- [ ] Agent-side: implement shared cache store and restore. +- [ ] Panel-side: validate `is_cacheable` before passing cache-mode hint to agent. +- [ ] Panel-side: expose cache hit/miss in install history log. +- [ ] Shared cache path configurable in panel settings (`server_content_cache_path`). diff --git a/Panel/modules/addonsmanager/addons_installer.php b/Panel/modules/addonsmanager/addons_installer.php index 7e86b645..240050ef 100644 --- a/Panel/modules/addonsmanager/addons_installer.php +++ b/Panel/modules/addonsmanager/addons_installer.php @@ -161,7 +161,7 @@ function exec_ogp_module() { { $addon_id = (int)$_REQUEST['addon_id']; - $addons_rows = $db->resultQuery("SELECT url, path, post_script FROM OGP_DB_PREFIXaddons WHERE addon_id=".$addon_id.$query_groups); + $addons_rows = $db->resultQuery("SELECT url, path, post_script, addon_type, install_method, content_version, requires_stop, restart_after_install FROM OGP_DB_PREFIXaddons WHERE addon_id=".$addon_id.$query_groups); if (!is_array($addons_rows)) { $addons_rows = []; } @@ -174,7 +174,23 @@ function exec_ogp_module() { $remote = new OGPRemoteLibrary($home_info['agent_ip'],$home_info['agent_port'],$home_info['encryption_key'],$home_info['timeout']); - $addon_info = $addons_rows[0]; + $addon_info = $addons_rows[0]; + $install_method = isset($addon_info['install_method']) ? $addon_info['install_method'] : 'download_zip'; + $content_version = isset($addon_info['content_version']) ? $addon_info['content_version'] : ''; + $requires_stop = !empty($addon_info['requires_stop']) ? 1 : 0; + + // ── requires_stop guard ─────────────────────────────────────────────── + // If the content item requires the server to be stopped first, check + // whether the server is currently running and block the install if so. + // (Phase 2 blocks install; automatic stop/start is Phase 3.) + if ( $state == "start" && $requires_stop ) { + $is_running = $remote->is_screen_running( $home_info['home_name'], $home_info['home_id'] ); + if ( $is_running === 1 ) { + print_failure('This content item requires the server to be stopped before installing. Please stop the server and try again.'); + echo "
"; + return; + } + } $url = $addon_info['url']; $filename = basename($url); #### Replace template variables in the post-install script with @@ -231,8 +247,22 @@ function exec_ogp_module() { } #### end of replacements - if ( $state == "start" AND $addon_id != "" ) + if ( $state == "start" AND $addon_id != "" ) { + // Record install attempt in history before triggering download. + $cache_mode = scm_get_cache_mode($db); + $history_id = scm_record_install_start( + $db, + $home_id, + $addon_id, + $user_id, + $addon_info['url'], + $content_version, + $install_method, + $cache_mode + ); + $_SESSION['scm_history_id_' . $home_id . '_' . $addon_id] = $history_id; $pid = $remote->start_file_download( $addon_info['url'], $home_info['home_path']."/".$addon_info['path'], $filename, "uncompress", $post_script); + } $headers = get_headers($url, 1); @@ -301,6 +331,19 @@ function exec_ogp_module() { elseif( $remote->is_file_download_in_progress($pid) === 0 AND $remote->is_screen_running("post_script",$pid) === 0 ) { print_success(get_lang('addon_installed_successfully')); + // Update install history and manifest on successful completion. + $history_key = 'scm_history_id_' . $home_id . '_' . $addon_id; + if (!empty($_SESSION[$history_key])) { + scm_record_install_done($db, (int)$_SESSION[$history_key], 'installed', 0); + unset($_SESSION[$history_key]); + } + scm_upsert_manifest($db, $home_id, $addon_id, array( + 'install_method' => $install_method, + 'content_version' => $content_version, + 'install_state' => 'installed', + 'source_url' => $addon_info['url'], + 'installed_by' => $user_id, + )); echo ""; $view->refresh("?m=addonsmanager&p=user_addons&home_id=$home_id". diff --git a/Panel/modules/addonsmanager/addons_manager.php b/Panel/modules/addonsmanager/addons_manager.php index 0527f987..ef9ecde3 100644 --- a/Panel/modules/addonsmanager/addons_manager.php +++ b/Panel/modules/addonsmanager/addons_manager.php @@ -25,16 +25,21 @@ // Central category map — defines all valid addon_type values and their labels. require_once(dirname(__FILE__) . '/server_content_categories.php'); +require_once(dirname(__FILE__) . '/server_content_helpers.php'); function exec_ogp_module() { global $db; + // Ensure Phase 2 schema is present (idempotent). + scm_ensure_phase2_schema($db); + // Build the complete list of allowed content types from the category map. // Admins can create items of any registered type; the original three types // (plugin, mappack, config) are always included. $addon_types = get_server_content_type_keys(); // all keys $addon_type_labels = get_server_content_categories(); // key => label + $install_methods = scm_get_install_methods(); // install_method keys => labels if (isset($_POST['create_addon']) AND isset($_POST['name']) AND $_POST['url']=="") { @@ -54,13 +59,21 @@ function exec_ogp_module() { } elseif (isset($_POST['create_addon']) AND isset($_POST['name']) AND isset($_POST['url']) AND isset($_POST['addon_type']) and isset($_POST['home_cfg_id']) ) { - $fields['name'] = $_POST['name']; - $fields['url'] = $_POST['url']; - $fields['path'] = $_POST['path']; - $fields['addon_type'] = $_POST['addon_type']; - $fields['home_cfg_id'] = $_POST['home_cfg_id']; - $fields['post_script'] = $_POST['post_script']; - $fields['group_id'] = $_POST['group_id']; + $valid_install_methods = array_keys($install_methods); + $fields['name'] = $_POST['name']; + $fields['url'] = $_POST['url']; + $fields['path'] = $_POST['path']; + $fields['addon_type'] = $_POST['addon_type']; + $fields['home_cfg_id'] = $_POST['home_cfg_id']; + $fields['post_script'] = $_POST['post_script']; + $fields['group_id'] = $_POST['group_id']; + $fields['install_method'] = in_array($_POST['install_method'], $valid_install_methods) ? $_POST['install_method'] : 'download_zip'; + $fields['content_version'] = isset($_POST['content_version']) ? $_POST['content_version'] : ''; + $fields['requires_stop'] = !empty($_POST['requires_stop']) ? 1 : 0; + $fields['backup_before_install'] = !empty($_POST['backup_before_install']) ? 1 : 0; + $fields['restart_after_install'] = !empty($_POST['restart_after_install']) ? 1 : 0; + $fields['is_cacheable'] = !empty($_POST['is_cacheable']) ? 1 : 0; + $fields['description'] = isset($_POST['description']) ? $_POST['description'] : ''; if( is_numeric($db->resultInsertId( 'addons', $fields )) ) { print_success(get_lang_f("addon_has_been_created",$_POST['name'])); @@ -70,13 +83,20 @@ function exec_ogp_module() { } echo "