diff --git a/Panel/js/modules/addonsmanager.js b/Panel/js/modules/addonsmanager.js index a8856049..dce93ed6 100644 --- a/Panel/js/modules/addonsmanager.js +++ b/Panel/js/modules/addonsmanager.js @@ -11,14 +11,16 @@ $(function() { download_zip: ['#scm-row-url', '#scm-row-path'], steam_workshop: ['#scm-row-workshop-xml-info'], post_script: ['#scm-row-post-script'], - config_edit: ['#scm-row-path', '#scm-row-config-edit-rule'] + config_edit: ['#scm-row-path', '#scm-row-config-edit-rule'], + server_app: ['.scm-row-server-app'] }; var allRows = [ '#scm-row-url', '#scm-row-path', '#scm-row-workshop-xml-info', '#scm-row-post-script', - '#scm-row-config-edit-rule' + '#scm-row-config-edit-rule', + '.scm-row-server-app' ]; var $method = $('#scm-install-method'); var $help = $('#scm-install-method-help'); diff --git a/Panel/modules/addonsmanager/addons_installer.php b/Panel/modules/addonsmanager/addons_installer.php index e5319d0a..67b94521 100644 --- a/Panel/modules/addonsmanager/addons_installer.php +++ b/Panel/modules/addonsmanager/addons_installer.php @@ -171,7 +171,7 @@ function exec_ogp_module() { { $addon_id = (int)$_REQUEST['addon_id']; - $addons_rows = $db->resultQuery("SELECT url, path, post_script, addon_type, install_method, content_version, requires_stop, restart_after_install, workshop_item_id, workshop_app_id, target_path_template, optional_folder_name, config_edit_rule, launch_param_additions, name 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, workshop_item_id, workshop_app_id, target_path_template, optional_folder_name, config_edit_rule, launch_param_additions, name, hook_name, hook_enabled, hook_platform, hook_working_dir, hook_start_command, hook_stop_command, hook_start_timing, hook_stop_with_server, hook_watch, hook_critical, hook_kill_game_if_app_exits, hook_restart_app_if_exits, hook_pid_name, hook_app_name, hook_description FROM OGP_DB_PREFIXaddons WHERE addon_id=".$addon_id.$query_groups); if (!is_array($addons_rows)) { $addons_rows = []; } @@ -183,6 +183,15 @@ function exec_ogp_module() { } $remote = new OGPRemoteLibrary($home_info['agent_ip'],$home_info['agent_port'],$home_info['encryption_key'],$home_info['timeout']); + if ($state == "start") { + $gsp_content_base = rtrim((string)$home_info['home_path'], '/\\') . '/_gsp_content'; + $remote->exec( + 'mkdir -p ' . + scm_remote_shell_quote($gsp_content_base . '/hooks') . ' ' . + scm_remote_shell_quote($gsp_content_base . '/generated') . ' ' . + scm_remote_shell_quote($gsp_content_base . '/runtime') + ); + } $addon_info = $addons_rows[0]; $install_method = scm_get_install_method_default(isset($addon_info['install_method']) ? $addon_info['install_method'] : 'download_zip'); @@ -393,6 +402,44 @@ function exec_ogp_module() { echo "

".get_lang('back')."

"; return; } + if ($install_method === 'server_app') { + $hook_message = ''; + $hook_ok = scm_install_server_app_hook($remote, $home_info, $addon_info, $hook_message); + if ($hook_ok) { + scm_record_install_done($db, (int)$history_id, 'installed', 0, 'hook_manifest=' . $hook_message); + scm_upsert_manifest($db, $home_id, $addon_id, array( + 'install_method' => $install_method, + 'content_version' => $content_version, + 'install_state' => 'installed', + 'source_url' => '', + 'installed_by' => $user_id, + )); + scm_log_content_install_action(array( + 'addon_id' => (int)$addon_id, + 'addon_name' => isset($addon_info['name']) ? $addon_info['name'] : '', + 'content_type' => $install_method, + 'home_id' => (int)$home_id, + 'home_cfg_id' => (int)$home_info['home_cfg_id'], + 'target_path' => $hook_message, + 'action' => 'succeeded', + )); + print_success('Server-side application hook installed.'); + } else { + scm_record_install_done($db, (int)$history_id, 'failed', 1, $hook_message); + scm_log_content_install_action(array( + 'addon_id' => (int)$addon_id, + 'addon_name' => isset($addon_info['name']) ? $addon_info['name'] : '', + 'content_type' => $install_method, + 'home_id' => (int)$home_id, + 'home_cfg_id' => (int)$home_info['home_cfg_id'], + 'action' => 'failed', + 'error' => $hook_message, + )); + print_failure($hook_message); + } + echo "

".get_lang('back')."

"; + return; + } if ($install_method === 'config_edit' || $install_method === 'create_folder') { $placeholder_map = scm_build_placeholder_map($home_info, array('exe_location' => (string)$server_xml->exe_location)); $target_template = trim((string)$addon_info['path']); diff --git a/Panel/modules/addonsmanager/addons_manager.php b/Panel/modules/addonsmanager/addons_manager.php index fd254d58..08003875 100644 --- a/Panel/modules/addonsmanager/addons_manager.php +++ b/Panel/modules/addonsmanager/addons_manager.php @@ -72,6 +72,21 @@ function exec_ogp_module() { $fields['optional_folder_name']= ''; $fields['config_edit_rule'] = isset($_POST['config_edit_rule']) ? trim((string)$_POST['config_edit_rule']) : ''; $fields['launch_param_additions'] = ''; + $fields['hook_name'] = isset($_POST['hook_name']) ? trim((string)$_POST['hook_name']) : ''; + $fields['hook_enabled'] = !empty($_POST['hook_enabled']) ? 1 : 0; + $fields['hook_platform'] = scm_normalize_hook_platform(isset($_POST['hook_platform']) ? $_POST['hook_platform'] : 'both'); + $fields['hook_working_dir'] = isset($_POST['hook_working_dir']) ? trim((string)$_POST['hook_working_dir']) : ''; + $fields['hook_start_command'] = isset($_POST['hook_start_command']) ? trim((string)$_POST['hook_start_command']) : ''; + $fields['hook_stop_command'] = isset($_POST['hook_stop_command']) ? trim((string)$_POST['hook_stop_command']) : ''; + $fields['hook_start_timing'] = scm_normalize_hook_start_timing(isset($_POST['hook_start_timing']) ? $_POST['hook_start_timing'] : 'before_server'); + $fields['hook_stop_with_server'] = !empty($_POST['hook_stop_with_server']) ? 1 : 0; + $fields['hook_watch'] = !empty($_POST['hook_watch']) ? 1 : 0; + $fields['hook_critical'] = !empty($_POST['hook_critical']) ? 1 : 0; + $fields['hook_kill_game_if_app_exits'] = !empty($_POST['hook_kill_game_if_app_exits']) ? 1 : 0; + $fields['hook_restart_app_if_exits'] = !empty($_POST['hook_restart_app_if_exits']) ? 1 : 0; + $fields['hook_pid_name'] = isset($_POST['hook_pid_name']) ? trim((string)$_POST['hook_pid_name']) : ''; + $fields['hook_app_name'] = isset($_POST['hook_app_name']) ? trim((string)$_POST['hook_app_name']) : ''; + $fields['hook_description'] = isset($_POST['hook_description']) ? trim((string)$_POST['hook_description']) : ''; $fields['addon_type'] = scm_get_addon_type_from_install_method($fields['install_method']); if ($fields['install_method'] === 'steam_workshop') { $fields['url'] = ''; @@ -102,6 +117,21 @@ function exec_ogp_module() { 'target_path_template' => $fields['target_path_template'], 'post_script' => $fields['post_script'], 'config_edit_rule' => $fields['config_edit_rule'], + 'hook_name' => $fields['hook_name'], + 'hook_enabled' => $fields['hook_enabled'], + 'hook_platform' => $fields['hook_platform'], + 'hook_working_dir' => $fields['hook_working_dir'], + 'hook_start_command' => $fields['hook_start_command'], + 'hook_stop_command' => $fields['hook_stop_command'], + 'hook_start_timing' => $fields['hook_start_timing'], + 'hook_stop_with_server' => $fields['hook_stop_with_server'], + 'hook_watch' => $fields['hook_watch'], + 'hook_critical' => $fields['hook_critical'], + 'hook_kill_game_if_app_exits' => $fields['hook_kill_game_if_app_exits'], + 'hook_restart_app_if_exits' => $fields['hook_restart_app_if_exits'], + 'hook_pid_name' => $fields['hook_pid_name'], + 'hook_app_name' => $fields['hook_app_name'], + 'hook_description' => $fields['hook_description'], ); $validation_message = ''; if (!scm_validate_install_method_payload($fields['install_method'], $validation_payload, $validation_message)) @@ -133,6 +163,21 @@ function exec_ogp_module() { $is_cacheable = isset($_POST['is_cacheable']) ? (int)$_POST['is_cacheable'] : 0; $description = isset($_POST['description']) ? $_POST['description'] : ""; $config_edit_rule = isset($_POST['config_edit_rule']) ? $_POST['config_edit_rule'] : ""; + $hook_name = isset($_POST['hook_name']) ? $_POST['hook_name'] : ""; + $hook_enabled = isset($_POST['hook_enabled']) ? (int)$_POST['hook_enabled'] : 1; + $hook_platform = isset($_POST['hook_platform']) ? $_POST['hook_platform'] : "both"; + $hook_working_dir = isset($_POST['hook_working_dir']) ? $_POST['hook_working_dir'] : ""; + $hook_start_command = isset($_POST['hook_start_command']) ? $_POST['hook_start_command'] : ""; + $hook_stop_command = isset($_POST['hook_stop_command']) ? $_POST['hook_stop_command'] : ""; + $hook_start_timing = isset($_POST['hook_start_timing']) ? $_POST['hook_start_timing'] : "before_server"; + $hook_stop_with_server = isset($_POST['hook_stop_with_server']) ? (int)$_POST['hook_stop_with_server'] : 1; + $hook_watch = isset($_POST['hook_watch']) ? (int)$_POST['hook_watch'] : 1; + $hook_critical = isset($_POST['hook_critical']) ? (int)$_POST['hook_critical'] : 0; + $hook_kill_game_if_app_exits = isset($_POST['hook_kill_game_if_app_exits']) ? (int)$_POST['hook_kill_game_if_app_exits'] : 0; + $hook_restart_app_if_exits = isset($_POST['hook_restart_app_if_exits']) ? (int)$_POST['hook_restart_app_if_exits'] : 1; + $hook_pid_name = isset($_POST['hook_pid_name']) ? $_POST['hook_pid_name'] : ""; + $hook_app_name = isset($_POST['hook_app_name']) ? $_POST['hook_app_name'] : ""; + $hook_description = isset($_POST['hook_description']) ? $_POST['hook_description'] : ""; if (isset($_POST['addon_id']) && (int)$_POST['addon_id'] > 0 && isset($_POST['edit'])) { @@ -156,6 +201,21 @@ function exec_ogp_module() { $is_cacheable = isset($addon_info['is_cacheable']) ? (int)$addon_info['is_cacheable'] : 0; $description = isset($addon_info['description']) ? $addon_info['description'] : ""; $config_edit_rule = isset($addon_info['config_edit_rule']) ? $addon_info['config_edit_rule'] : ""; + $hook_name = isset($addon_info['hook_name']) ? $addon_info['hook_name'] : ""; + $hook_enabled = isset($addon_info['hook_enabled']) ? (int)$addon_info['hook_enabled'] : 1; + $hook_platform = isset($addon_info['hook_platform']) ? $addon_info['hook_platform'] : "both"; + $hook_working_dir = isset($addon_info['hook_working_dir']) ? $addon_info['hook_working_dir'] : ""; + $hook_start_command = isset($addon_info['hook_start_command']) ? $addon_info['hook_start_command'] : ""; + $hook_stop_command = isset($addon_info['hook_stop_command']) ? $addon_info['hook_stop_command'] : ""; + $hook_start_timing = isset($addon_info['hook_start_timing']) ? $addon_info['hook_start_timing'] : "before_server"; + $hook_stop_with_server = isset($addon_info['hook_stop_with_server']) ? (int)$addon_info['hook_stop_with_server'] : 1; + $hook_watch = isset($addon_info['hook_watch']) ? (int)$addon_info['hook_watch'] : 1; + $hook_critical = isset($addon_info['hook_critical']) ? (int)$addon_info['hook_critical'] : 0; + $hook_kill_game_if_app_exits = isset($addon_info['hook_kill_game_if_app_exits']) ? (int)$addon_info['hook_kill_game_if_app_exits'] : 0; + $hook_restart_app_if_exits = isset($addon_info['hook_restart_app_if_exits']) ? (int)$addon_info['hook_restart_app_if_exits'] : 1; + $hook_pid_name = isset($addon_info['hook_pid_name']) ? $addon_info['hook_pid_name'] : ""; + $hook_app_name = isset($addon_info['hook_app_name']) ? $addon_info['hook_app_name'] : ""; + $hook_description = isset($addon_info['hook_description']) ? $addon_info['hook_description'] : ""; } ?>
@@ -237,6 +297,78 @@ function exec_ogp_module() { + + Application name + + + + + + + Platform + + +   + + + + + Working directory + + + Relative to the server home unless an absolute path is required. + + + + Start command + + + + + + Stop command + + + + + + PID / app names + + + + Optional names used in runtime PID records and fallback cleanup. + + + + Lifecycle behavior + + +    + +    + +
+ +    + + + + + Hook description + + + + diff --git a/Panel/modules/addonsmanager/server_content_categories.php b/Panel/modules/addonsmanager/server_content_categories.php index cae883d1..1740f54d 100644 --- a/Panel/modules/addonsmanager/server_content_categories.php +++ b/Panel/modules/addonsmanager/server_content_categories.php @@ -42,6 +42,7 @@ function get_server_content_categories() 'file_download' => 'Downloadable Mod', 'config_edit' => 'Configuration Package', 'scripted_installer' => 'Scripted Installer', + 'server_app' => 'Server-side Application', ); } @@ -79,6 +80,7 @@ function scm_get_addon_type_from_install_method($install_method) 'download_zip' => 'file_download', 'config_edit' => 'config_edit', 'post_script' => 'scripted_installer', + 'server_app' => 'server_app', ); return isset($map[$install_method]) ? $map[$install_method] : 'file_download'; } diff --git a/Panel/modules/addonsmanager/server_content_helpers.php b/Panel/modules/addonsmanager/server_content_helpers.php index 711c25de..b41f0acf 100644 --- a/Panel/modules/addonsmanager/server_content_helpers.php +++ b/Panel/modules/addonsmanager/server_content_helpers.php @@ -696,6 +696,7 @@ function scm_get_install_methods() 'download_zip' => 'Downloadable Mod', 'config_edit' => 'Configuration Package', 'post_script' => 'Scripted Installer', + 'server_app' => 'Server-side Application', ); } @@ -705,6 +706,7 @@ function scm_get_install_method_help_text() 'download_zip' => 'Download and extract a ZIP, RAR, or archive file.', 'config_edit' => 'Install configuration files, profiles, or templates.', 'post_script' => 'Run a custom scripted installation process.', + 'server_app' => 'Install a server-side application hook managed by the agent lifecycle.', ); } @@ -715,6 +717,7 @@ function scm_get_install_method_required_fields() 'steam_workshop' => array(), // No required fields; users provide Workshop IDs on their server page 'post_script' => array('post_script'), 'config_edit' => array('path', 'config_edit_rule'), + 'server_app' => array('hook_name', 'hook_platform', 'hook_working_dir', 'hook_start_command'), ); } @@ -724,6 +727,7 @@ function scm_get_install_method_validation_errors() 'download_zip' => 'Please enter a download URL.', 'config_edit' => 'Please enter the config target and edit action.', 'post_script' => 'Please enter the installer script/action.', + 'server_app' => 'Please enter the application name, platform, working directory, and start command.', ); } @@ -752,6 +756,21 @@ function scm_get_install_payload_keys() 'post_script', 'config_edit_rule', 'launch_param_additions', + 'hook_name', + 'hook_enabled', + 'hook_platform', + 'hook_working_dir', + 'hook_start_command', + 'hook_stop_command', + 'hook_start_timing', + 'hook_stop_with_server', + 'hook_watch', + 'hook_critical', + 'hook_kill_game_if_app_exits', + 'hook_restart_app_if_exits', + 'hook_pid_name', + 'hook_app_name', + 'hook_description', ); } @@ -844,6 +863,83 @@ function scm_validate_configuration_package(array $payload, &$message = '') return true; } +function scm_normalize_hook_platform($platform) +{ + $platform = strtolower(trim((string)$platform)); + return in_array($platform, array('windows', 'linux', 'both'), true) ? $platform : 'both'; +} + +function scm_normalize_hook_start_timing($start_timing) +{ + $start_timing = strtolower(trim((string)$start_timing)); + return in_array($start_timing, array('before_server', 'after_server'), true) ? $start_timing : 'before_server'; +} + +function scm_bool_to_int($value) +{ + return !empty($value) ? 1 : 0; +} + +function scm_hook_safe_filename($name) +{ + $name = strtolower(trim((string)$name)); + $name = preg_replace('/[^a-z0-9._-]+/', '_', $name); + $name = trim($name, '._-'); + return $name !== '' ? $name : 'server_app'; +} + +function scm_validate_server_app_content(array $payload, &$message = '') +{ + $name = isset($payload['hook_name']) ? trim((string)$payload['hook_name']) : ''; + $working_dir = isset($payload['hook_working_dir']) ? trim((string)$payload['hook_working_dir']) : ''; + $start_command = isset($payload['hook_start_command']) ? trim((string)$payload['hook_start_command']) : ''; + if ($name === '' || $working_dir === '' || $start_command === '') { + $message = 'Please enter the application name, working directory, and start command.'; + return false; + } + if (strpos($working_dir, '..') !== false) { + $message = 'Hook working directory must not contain parent-directory traversal.'; + return false; + } + $platform = scm_normalize_hook_platform(isset($payload['hook_platform']) ? $payload['hook_platform'] : ''); + if (!in_array($platform, array('windows', 'linux', 'both'), true)) { + $message = 'Hook platform must be Windows, Linux, or Both.'; + return false; + } + $message = ''; + return true; +} + +function scm_build_server_app_hook_manifest(array $addon_info) +{ + $name = trim((string)(isset($addon_info['hook_name']) && $addon_info['hook_name'] !== '' ? $addon_info['hook_name'] : (isset($addon_info['name']) ? $addon_info['name'] : 'Server App'))); + $pid_name = trim((string)(isset($addon_info['hook_pid_name']) ? $addon_info['hook_pid_name'] : '')); + $app_name = trim((string)(isset($addon_info['hook_app_name']) ? $addon_info['hook_app_name'] : '')); + if ($pid_name === '') { + $pid_name = scm_hook_safe_filename($name); + } + if ($app_name === '') { + $app_name = $pid_name; + } + return array( + 'name' => $name, + 'enabled' => !empty($addon_info['hook_enabled']), + 'platform' => scm_normalize_hook_platform(isset($addon_info['hook_platform']) ? $addon_info['hook_platform'] : 'both'), + 'working_dir' => trim((string)(isset($addon_info['hook_working_dir']) ? $addon_info['hook_working_dir'] : '')), + 'start_command' => trim((string)(isset($addon_info['hook_start_command']) ? $addon_info['hook_start_command'] : '')), + 'stop_command' => trim((string)(isset($addon_info['hook_stop_command']) ? $addon_info['hook_stop_command'] : '')), + 'start_timing' => scm_normalize_hook_start_timing(isset($addon_info['hook_start_timing']) ? $addon_info['hook_start_timing'] : 'before_server'), + 'stop_with_server' => !empty($addon_info['hook_stop_with_server']), + 'watch' => !empty($addon_info['hook_watch']), + 'critical' => !empty($addon_info['hook_critical']), + 'kill_game_if_app_exits' => !empty($addon_info['hook_kill_game_if_app_exits']), + 'restart_app_if_exits' => !empty($addon_info['hook_restart_app_if_exits']), + 'pid_name' => $pid_name, + 'app_name' => $app_name, + 'description' => trim((string)(isset($addon_info['hook_description']) ? $addon_info['hook_description'] : '')), + ); +} + function scm_validate_install_method_payload($install_method, array $payload, &$message = '') { $install_method = scm_get_install_method_default($install_method); @@ -864,6 +960,9 @@ function scm_validate_install_method_payload($install_method, array $payload, &$ if ($install_method === 'config_edit') { return scm_validate_configuration_package($payload, $message); } + if ($install_method === 'server_app') { + return scm_validate_server_app_content($payload, $message); + } $message = ''; return true; } @@ -1097,6 +1196,21 @@ function scm_ensure_phase2_schema($db) 'max_workshop_ids' => "INT NULL", 'required_workshop_ids' => "TEXT NULL", 'blocked_workshop_ids' => "TEXT NULL", + 'hook_name' => "VARCHAR(128) NULL", + 'hook_enabled' => "TINYINT(1) NOT NULL DEFAULT 1", + 'hook_platform' => "VARCHAR(16) NOT NULL DEFAULT 'both'", + 'hook_working_dir' => "VARCHAR(255) NULL", + 'hook_start_command' => "TEXT NULL", + 'hook_stop_command' => "TEXT NULL", + 'hook_start_timing' => "VARCHAR(32) NOT NULL DEFAULT 'before_server'", + 'hook_stop_with_server' => "TINYINT(1) NOT NULL DEFAULT 1", + 'hook_watch' => "TINYINT(1) NOT NULL DEFAULT 1", + 'hook_critical' => "TINYINT(1) NOT NULL DEFAULT 0", + 'hook_kill_game_if_app_exits' => "TINYINT(1) NOT NULL DEFAULT 0", + 'hook_restart_app_if_exits' => "TINYINT(1) NOT NULL DEFAULT 1", + 'hook_pid_name' => "VARCHAR(128) NULL", + 'hook_app_name' => "VARCHAR(128) NULL", + 'hook_description' => "TEXT NULL", ); foreach ($new_columns as $col => $definition) { $escaped_col = $db->realEscapeSingle($col); @@ -1296,3 +1410,55 @@ function scm_upsert_manifest($db, $home_id, $addon_id, array $fields = array()) updated_at = NOW()" ); } + +function scm_remote_shell_quote($value) +{ + return "'" . str_replace("'", "'\"'\"'", (string)$value) . "'"; +} + +function scm_install_server_app_hook($remote, array $home_info, array $addon_info, &$message = '') +{ + $home_path = rtrim((string)(isset($home_info['home_path']) ? $home_info['home_path'] : ''), '/\\'); + if ($home_path === '') { + $message = 'Server home path is missing.'; + return false; + } + $manifest = scm_build_server_app_hook_manifest($addon_info); + $validation_message = ''; + if (!scm_validate_server_app_content(array( + 'hook_name' => $manifest['name'], + 'hook_platform' => $manifest['platform'], + 'hook_working_dir' => $manifest['working_dir'], + 'hook_start_command' => $manifest['start_command'], + ), $validation_message)) { + $message = $validation_message; + return false; + } + + $json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $message = 'Failed to encode server application hook manifest.'; + return false; + } + + $base = $home_path . '/_gsp_content'; + $mkdir = 'mkdir -p ' . + scm_remote_shell_quote($base . '/hooks') . ' ' . + scm_remote_shell_quote($base . '/generated') . ' ' . + scm_remote_shell_quote($base . '/runtime'); + $mkdir_result = $remote->exec($mkdir . ' && echo __GSP_HOOK_DIRS_OK'); + if (!is_string($mkdir_result) || strpos($mkdir_result, '__GSP_HOOK_DIRS_OK') === false) { + $message = 'Failed to create server content hook directories.'; + return false; + } + + $filename = scm_hook_safe_filename($manifest['pid_name'] !== '' ? $manifest['pid_name'] : $manifest['name']) . '.json'; + $write_path = $base . '/hooks/' . $filename; + $write_result = $remote->remote_writefile($write_path, $json . PHP_EOL); + if ((int)$write_result !== 1) { + $message = 'Failed to write hook manifest to ' . $write_path . '.'; + return false; + } + $message = $write_path; + return true; +} diff --git a/docs/features/SERVER_CONTENT_APPLICATION_HOOKS.md b/docs/features/SERVER_CONTENT_APPLICATION_HOOKS.md new file mode 100644 index 00000000..95045289 --- /dev/null +++ b/docs/features/SERVER_CONTENT_APPLICATION_HOOKS.md @@ -0,0 +1,90 @@ +# Server Content Application Hooks + +Server Content Application Hooks let installed server content manage companion +applications such as BEC, Big Brother Bot, Discord bridges, RCON tools, and log +watchers as part of the game server lifecycle. + +The application files may live wherever the game requires them. GSP lifecycle +metadata lives under each server home: + +```text +_gsp_content/ + hooks/ + generated/ + runtime/ +``` + +## Content Type + +The Server Content Manager includes the `Server-side Application` content type. +When this type is installed, the Panel writes a JSON hook manifest to: + +```text +_gsp_content/hooks/.json +``` + +The agents read these manifests during server startup. + +## Hook Manifest + +```json +{ + "name": "BEC", + "enabled": true, + "platform": "windows", + "working_dir": "bec", + "start_command": "BEC.exe -f Config.cfg", + "stop_command": "", + "start_timing": "before_server", + "stop_with_server": true, + "watch": true, + "critical": true, + "kill_game_if_app_exits": false, + "restart_app_if_exits": true, + "pid_name": "bec", + "app_name": "BEC.exe", + "description": "Battleye Extended Controls watchdog for DayZ/Arma servers" +} +``` + +Supported `platform` values are `windows`, `linux`, and `both`. +Supported `start_timing` values are `before_server` and `after_server`. + +## Runtime PID File + +Agents write hook runtime PIDs to: + +```text +_gsp_content/runtime/server_content.pids +``` + +Format: + +```text +watchdog|BEC|12345 +app|BEC|12346 +``` + +The main game server watchdog PID is not stored in this file. + +## Lifecycle + +On server start, the agent: + +1. Creates `_gsp_content/hooks`, `_gsp_content/generated`, and `_gsp_content/runtime`. +2. Reads enabled hook manifests matching the agent platform. +3. Generates platform-specific hook watchdog scripts. +4. Starts `before_server` hooks. +5. Starts the game server. +6. Starts `after_server` hooks. +7. Cleans up hook watchdogs and apps when the game server exits. + +On Panel Stop or Restart, agents kill hook watchdog PIDs first and hook app PIDs +second, then continue normal game process and screen/session cleanup. + +## Legacy `_alsoRun.bat` + +Windows `_alsoRun.bat` support remains for compatibility, but it is deprecated. +New companion applications should be installed as `Server-side Application` +content so both Linux and Windows agents can manage them through the same hook +contract. diff --git a/docs/modules/SERVER_CONTENT_MANAGER.md b/docs/modules/SERVER_CONTENT_MANAGER.md index b0f989e2..4bc77c58 100644 --- a/docs/modules/SERVER_CONTENT_MANAGER.md +++ b/docs/modules/SERVER_CONTENT_MANAGER.md @@ -33,10 +33,31 @@ The module can already represent several content types, including: - downloads/extracted packages - post-script driven installs - config packs +- server-side applications with lifecycle hooks - future profile-type content Steam Workshop is no longer a user-facing Server Content category. Workshop access belongs to the dedicated `steam_workshop` module. +## Server-Side Applications + +`Server-side Application` content writes an agent-readable hook manifest under +the target game home: + +```text +_gsp_content/hooks/.json +``` + +The agents generate runtime watchdog scripts in `_gsp_content/generated/` and +track side-application PIDs in `_gsp_content/runtime/server_content.pids`. + +Use this type for companion applications such as BEC, Big Brother Bot, Discord +bridges, RCON tools, and log watchers. The application files themselves may +still be installed wherever the game requires them. + +Detailed lifecycle documentation: + +- `docs/features/SERVER_CONTENT_APPLICATION_HOOKS.md` + ## Current Limitations - Cache and cleanup policy need a clearer product design. @@ -56,6 +77,7 @@ This module is the right place for: - add-ons - config packs - script-driven installs +- server-side companion application hooks - server content manifests - install history