doc changes and reference folder

This commit is contained in:
Frank Harris 2026-06-06 17:18:49 -05:00
parent 11691a5876
commit 82cbc206eb
33 changed files with 1514 additions and 2855 deletions

View file

@ -1,158 +0,0 @@
#!/usr/bin/env bash
#
# GSP Deployment Script
# =====================
# This script deploys the Game Server Panel (GSP) from GitHub to a web server.
#
# HOW IT WORKS:
# 1. Clones/updates the GSP repository to a staging directory
# 2. Syncs files to the web root using rsync (preserving configs)
# 3. Sets proper permissions for OGP panel operation
#
# CONFIGURATION:
# All settings can be configured via environment variables or by editing
# the defaults in the "Config" section below.
#
# ENVIRONMENT VARIABLES:
# - REPO_URL: Git repository URL (default: https://github.com/GameServerPanel/GSP.git)
# - STAGE_DIR: Staging directory for git clone (default: $HOME/gsp_stage)
# - WEB_ROOT: Live web server directory (default: /var/www/html/panel)
# - OWNER: File owner user (default: www-data)
# - GROUP: File owner group (default: www-data)
# - SUDO: Command prefix for privilege escalation (default: sudo, set empty to skip)
# - DRY_RUN: Set to 1 to test without making changes (default: 0)
#
# EXAMPLE USAGE:
# # Use defaults:
# ./deploy_gsp.sh
#
# # Custom web root:
# WEB_ROOT=/home/panel/public_html ./deploy_gsp.sh
#
# # Dry run to test:
# DRY_RUN=1 ./deploy_gsp.sh
#
# # Different user/group:
# OWNER=apache GROUP=apache ./deploy_gsp.sh
#
set -Eeuo pipefail
umask 022
# ---------- Config (override via env if you like) ----------
REPO_URL="${REPO_URL:-https://github.com/GameServerPanel/GSP.git}"
STAGE_DIR="${STAGE_DIR:-$HOME/gsp_stage}" # keeps clone in your home folder
WEB_ROOT="${WEB_ROOT:-/var/www/html/panel}" # live site root
OWNER="${OWNER:-www-data}"
GROUP="${GROUP:-www-data}"
SUDO="${SUDO:-sudo}" # set SUDO= to skip sudo if not needed
DRY_RUN="${DRY_RUN:-0}" # set DRY_RUN=1 to test without writing
# Never overwrite these:
EXCLUDES=(
".git/"
"includes/config.inc.php"
"modules/billing/includes/config.inc.php"
)
# ---------- Helpers ----------
log(){ printf '[%s] %s\n' "$(date +'%F %T')" "$*"; }
trap 'rc=$?; log "ERROR on line $LINENO (exit $rc)"; exit $rc' ERR
# ---------- Requirements ----------
if ! command -v git >/dev/null 2>&1; then
log "Installing git + rsync..."
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update && $SUDO apt-get install -y git rsync
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y git rsync
elif command -v yum >/dev/null 2>&1; then
$SUDO yum install -y git rsync
else
log "git/rsync required; please install manually."
exit 1
fi
fi
# ---------- Prepare stage clone in home folder ----------
log "Stage dir: $STAGE_DIR"
mkdir -p "$STAGE_DIR"
if [[ ! -d "$STAGE_DIR/.git" ]]; then
log "Cloning $REPO_URL ..."
git clone --depth 1 "$REPO_URL" "$STAGE_DIR"
else
log "Fetching latest from origin..."
git -C "$STAGE_DIR" fetch --all --prune
fi
# Determine default branch (origin/HEAD), fallback to main/master
DEFAULT_BRANCH="$(git -C "$STAGE_DIR" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
DEFAULT_BRANCH="${DEFAULT_BRANCH#origin/}"
if [[ -z "${DEFAULT_BRANCH:-}" ]]; then
if git -C "$STAGE_DIR" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
DEFAULT_BRANCH="main"
else
DEFAULT_BRANCH="master"
fi
fi
log "Default branch: $DEFAULT_BRANCH"
# Reset stage to remote HEAD
git -C "$STAGE_DIR" checkout -B "$DEFAULT_BRANCH" "origin/$DEFAULT_BRANCH"
git -C "$STAGE_DIR" reset --hard "origin/$DEFAULT_BRANCH"
git -C "$STAGE_DIR" clean -fdx
COMMIT="$(git -C "$STAGE_DIR" rev-parse --short HEAD)"
log "Prepared commit: $COMMIT"
# ---------- Rsync to webroot (preserve configs) ----------
RSYNC_ARGS=(-a --delete --omit-dir-times --human-readable --progress --itemize-changes)
for e in "${EXCLUDES[@]}"; do RSYNC_ARGS+=(--exclude="$e"); done
if [[ "$DRY_RUN" == "1" ]]; then
RSYNC_ARGS+=(--dry-run)
log "DRY RUN enabled — no changes will be written."
fi
log "Syncing to $WEB_ROOT ..."
$SUDO mkdir -p "$WEB_ROOT"
$SUDO rsync "${RSYNC_ARGS[@]}" "$STAGE_DIR"/ "$WEB_ROOT"/
# ---------- Permissions tuned for OGP panel ----------
WEB_USER="${OWNER:-www-data}"
WEB_GROUP="${GROUP:-www-data}"
log "Setting base permissions (OGP-safe)…"
# Base ownership
$SUDO chown -R "$OWNER:$GROUP" "$WEB_ROOT"
# Safe defaults: dirs 755, files 644 (batched; no “arg list too long”)
$SUDO find "$WEB_ROOT" -type d -exec chmod 755 {} +
$SUDO find "$WEB_ROOT" -type f -exec chmod 644 {} +
# Writable dirs for OGP
WRITABLE_NAMES="templates_c cache logs uploads storage tmp"
for name in $WRITABLE_NAMES; do
$SUDO find "$WEB_ROOT" -type d -name "$name" -print0 | while IFS= read -r -d '' d; do
log "Making writable dir: $d"
$SUDO chown -R "$OWNER:$GROUP" "$d"
$SUDO chmod -R 2775 "$d"
if command -v setfacl >/dev/null 2>&1; then
$SUDO setfacl -R -m g:$GROUP:rwx -m d:g:$GROUP:rwx "$d" || true
fi
done
done
# Keep your configs tight (and preserved from rsync by the scripts excludes)
# If the panel needs to write them via web UI, relax to 660 and owner www-data.
CFG1="$WEB_ROOT/includes/config.inc.php"
CFG2="$WEB_ROOT/modules/billing/includes/config.inc.php"
for cfg in "$CFG1" "$CFG2"; do
if [[ -f "$cfg" ]]; then
$SUDO chown "$WEB_USER:$WEB_GROUP" "$cfg"
$SUDO chmod 640 "$cfg"
fi
done
# Ensure billing folder itself is Apache-friendly (readable/executable)
$SUDO find "$WEB_ROOT/modules/billing" -type d -print0 | xargs -0 -r $SUDO chmod 755
$SUDO find "$WEB_ROOT/modules/billing" -type f -print0 | xargs -0 -r $SUDO chmod 644
log "Permissions set for OGP panel + billing."

View file

@ -1 +0,0 @@
gsp

View file

@ -223,6 +223,12 @@ function scm_get_workshop_manifest_path(array $home_info)
function scm_extract_workshop_app_id($server_xml)
{
if (isset($server_xml->workshop_support->workshop_app_id)) {
$value = trim((string)$server_xml->workshop_support->workshop_app_id);
if ($value !== '' && preg_match('/^[0-9]+$/', $value)) {
return $value;
}
}
$candidates = array(
'workshop_app_id',
'workshop_appid',
@ -240,9 +246,53 @@ function scm_extract_workshop_app_id($server_xml)
return "";
}
function scm_extract_workshop_steam_app_id($server_xml)
{
if (isset($server_xml->workshop_support->steam_app_id)) {
$value = trim((string)$server_xml->workshop_support->steam_app_id);
if ($value !== '' && preg_match('/^[0-9]+$/', $value)) {
return $value;
}
}
return '';
}
function scm_extract_workshop_install_path($server_xml)
{
if (isset($server_xml->workshop_support->install_path)) {
$value = trim((string)$server_xml->workshop_support->install_path);
if ($value !== '' && preg_match('/^[^\\r\\n\\0]+$/', $value)) {
return $value;
}
}
return '';
}
function scm_workshop_xml_bool($value, $default = false)
{
$value = strtolower(trim((string)$value));
if ($value === '') {
return (bool)$default;
}
if (in_array($value, array('1', 'yes', 'true', 'on'), true)) {
return true;
}
if (in_array($value, array('0', 'no', 'false', 'off'), true)) {
return false;
}
return (bool)$default;
}
function scm_get_workshop_script_path(array $home_info, $server_xml)
{
$key = scm_is_windows_home($home_info) ? 'workshop_script_windows' : 'workshop_script_linux';
$nested_key = scm_is_windows_home($home_info) ? 'script_windows' : 'script_linux';
if (isset($server_xml->workshop_support->$nested_key)) {
$xml_path = trim((string)$server_xml->workshop_support->$nested_key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
if (isset($server_xml->$key)) {
$xml_path = trim((string)$server_xml->$key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
@ -255,14 +305,20 @@ function scm_get_workshop_script_path(array $home_info, $server_xml)
function scm_get_configured_workshop_script_path(array $home_info, $server_xml)
{
$key = scm_is_windows_home($home_info) ? 'workshop_script_windows' : 'workshop_script_linux';
if (!isset($server_xml->$key)) {
return '';
$nested_key = scm_is_windows_home($home_info) ? 'script_windows' : 'script_linux';
if (isset($server_xml->workshop_support->$nested_key)) {
$xml_path = trim((string)$server_xml->workshop_support->$nested_key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
$xml_path = trim((string)$server_xml->$key);
if ($xml_path === '' || !preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return '';
if (isset($server_xml->$key)) {
$xml_path = trim((string)$server_xml->$key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
return $xml_path;
return '';
}
function scm_get_bundled_workshop_script_source(array $home_info)
@ -658,13 +714,16 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml,
if ($fallback_workshop_app_id === '') {
$fallback_workshop_app_id = scm_extract_workshop_app_id($server_xml);
}
$steam_app_id = (is_array($fallback_profile) && !empty($fallback_profile['steam_app_id'])) ? (string)$fallback_profile['steam_app_id'] : '';
$steam_app_id = (is_array($fallback_profile) && !empty($fallback_profile['steam_app_id'])) ? (string)$fallback_profile['steam_app_id'] : scm_extract_workshop_steam_app_id($server_xml);
$folder_name = ($optional_folder_name !== '') ? $optional_folder_name : '@' . $workshop_item_id;
$effective_template = $target_path_template;
if ($effective_template === '') {
$effective_template = (is_array($fallback_profile) && !empty($fallback_profile['install_path_template']))
? (string)$fallback_profile['install_path_template']
: scm_get_default_workshop_target_template($install_strategy);
if (is_array($fallback_profile) && !empty($fallback_profile['install_path_template'])) {
$effective_template = (string)$fallback_profile['install_path_template'];
} else {
$xml_install_path = scm_extract_workshop_install_path($server_xml);
$effective_template = $xml_install_path !== '' ? $xml_install_path : scm_get_default_workshop_target_template($install_strategy);
}
}
$placeholder_map = scm_build_placeholder_map($home_info, array('exe_location' => isset($server_xml->exe_location) ? (string)$server_xml->exe_location : ''), array(
'WORKSHOP_ID' => $workshop_item_id,
@ -695,6 +754,12 @@ function scm_detect_workshop_install_strategy(array $home_info, $server_xml, arr
return strtolower($strategy);
}
}
if (isset($server_xml->workshop_support->install_strategy)) {
$strategy = trim((string)$server_xml->workshop_support->install_strategy);
if ($strategy !== '' && preg_match('/^[a-z0-9_\-]+$/i', $strategy)) {
return strtolower($strategy);
}
}
foreach (array('workshop_install_strategy', 'install_strategy') as $tag) {
if (isset($server_xml->$tag)) {
$strategy = trim((string)$server_xml->$tag);
@ -718,15 +783,33 @@ function scm_detect_workshop_install_strategy(array $home_info, $server_xml, arr
function scm_workshop_should_copy_keys($server_xml, $install_strategy)
{
if (isset($server_xml->workshop_support->copy_keys)) {
$attrs = $server_xml->workshop_support->copy_keys->attributes();
if (isset($attrs['enabled'])) {
return scm_workshop_xml_bool((string)$attrs['enabled'], false);
}
}
foreach (array('workshop_copy_keys', 'copy_workshop_keys') as $tag) {
if (isset($server_xml->$tag)) {
$value = strtolower(trim((string)$server_xml->$tag));
return in_array($value, array('1', 'yes', 'true', 'on'), true);
return scm_workshop_xml_bool((string)$server_xml->$tag, false);
}
}
return in_array((string)$install_strategy, array('dayz_mod_folder', 'arma_mod_folder'), true);
}
function scm_workshop_keys_target_path($server_xml, array $home_info)
{
$template = '';
if (isset($server_xml->workshop_support->copy_keys->target_path)) {
$template = trim((string)$server_xml->workshop_support->copy_keys->target_path);
}
if ($template === '') {
$template = '{SERVER_ROOT}/keys';
}
$map = scm_build_placeholder_map($home_info);
return scm_apply_placeholders($template, $map);
}
function scm_build_placeholder_map(array $home_info, array $server_context = array(), array $overrides = array())
{
$home_id = (int)(isset($home_info['home_id']) ? $home_info['home_id'] : 0);

View file

@ -82,7 +82,7 @@ scm_workshop_test_assert($script === '/cygdrive/c/OGP_User_Files/11/gsp_server_c
scm_workshop_test_assert(isset($windowsRemote->files[$script]), 'writes Windows/Cygwin bundled script to fake agent');
scm_workshop_test_assert($error === '', 'default Windows/Cygwin script staging does not report missing script');
$configuredXml = simplexml_load_string('<game_config><workshop_script_linux>/agent/custom/workshop.sh</workshop_script_linux></game_config>');
$configuredXml = simplexml_load_string('<game_config><workshop_support><script_linux>/agent/custom/workshop.sh</script_linux></workshop_support></game_config>');
$customRemote = new ScmWorkshopFakeRemote();
$customRemote->existing[] = '/agent/custom/workshop.sh';
$script = scm_prepare_workshop_script_for_agent($customRemote, $linuxHome, $configuredXml, $error);
@ -110,7 +110,13 @@ $runtime = scm_build_workshop_runtime_context(new stdClass(), $linuxHome, $empty
scm_workshop_test_assert($runtime['target_path_template'] === '{SERVER_ROOT}/{MOD_FOLDER}', 'Arma strategy keeps @mod folder at server root');
scm_workshop_test_assert($runtime['target_path_resolved'] === '/srv/games/arma3/@450814997', 'Arma target path resolves to root @WorkshopID folder');
$appXml = simplexml_load_string('<game_config><workshop_app_id>107410</workshop_app_id></game_config>');
scm_workshop_test_assert(scm_extract_workshop_app_id($appXml) === '107410', 'extracts explicit Workshop app ID from game XML');
$appXml = simplexml_load_string('<game_config><workshop_support><steam_app_id>107410</steam_app_id><workshop_app_id>107410</workshop_app_id><install_strategy>arma_mod_folder</install_strategy><install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path><copy_keys enabled="1"><target_path>{SERVER_ROOT}/keys</target_path></copy_keys></workshop_support></game_config>');
scm_workshop_test_assert(scm_extract_workshop_app_id($appXml) === '107410', 'extracts canonical Workshop app ID from game XML');
scm_workshop_test_assert(scm_extract_workshop_steam_app_id($appXml) === '107410', 'extracts canonical Steam app ID from game XML');
scm_workshop_test_assert(scm_detect_workshop_install_strategy($linuxHome, $appXml) === 'arma_mod_folder', 'extracts canonical Workshop install strategy from game XML');
$runtime = scm_build_workshop_runtime_context(new stdClass(), $linuxHome, $appXml, array('workshop_item_id' => '450814997'), $message);
scm_workshop_test_assert($runtime['target_path_template'] === '{SERVER_ROOT}/{MOD_FOLDER}', 'canonical Workshop install_path controls target template');
scm_workshop_test_assert($runtime['target_path_resolved'] === '/srv/games/arma3/@450814997', 'canonical Workshop install_path resolves under server root');
scm_workshop_test_assert(scm_workshop_should_copy_keys($appXml, 'copy_to_mod_folder') === true, 'canonical copy_keys enabled flag is honored');
echo "All Workshop helper smoke tests passed.\n";

View file

@ -88,6 +88,8 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
{
$install_strategy = scm_detect_workshop_install_strategy($home_info, $server_xml, $template);
$copy_keys = scm_workshop_should_copy_keys($server_xml, $install_strategy);
$xml_install_path = scm_extract_workshop_install_path($server_xml);
$keys_target_path = scm_workshop_keys_target_path($server_xml, $home_info);
$item_details = array();
$resolved_app_id = '';
$steam_app_id = '';
@ -127,13 +129,16 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
'target_path_resolved' => isset($runtime['target_path_resolved']) ? (string)$runtime['target_path_resolved'] : '',
'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0,
'keys_target_path' => rtrim((string)$home_info['home_path'], '/') . '/keys',
'keys_target_path' => $keys_target_path,
);
}
if ($resolved_app_id === '') {
$resolved_app_id = scm_extract_workshop_app_id($server_xml);
}
if ($steam_app_id === '') {
$steam_app_id = scm_extract_workshop_steam_app_id($server_xml);
}
return array(
'workshop_app_id' => $resolved_app_id,
@ -141,7 +146,8 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
'server_root' => rtrim((string)$home_info['home_path'], '/'),
'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0,
'target_path_template' => isset($template['target_path_template']) && trim((string)$template['target_path_template']) !== '' ? trim((string)$template['target_path_template']) : scm_get_default_workshop_target_template($install_strategy),
'target_path_template' => isset($template['target_path_template']) && trim((string)$template['target_path_template']) !== '' ? trim((string)$template['target_path_template']) : ($xml_install_path !== '' ? $xml_install_path : scm_get_default_workshop_target_template($install_strategy)),
'keys_target_path' => $keys_target_path,
'post_install_script' => isset($template['post_script']) ? trim((string)$template['post_script']) : '',
'launch_param_additions' => isset($template['launch_param_additions']) ? trim((string)$template['launch_param_additions']) : '',
'content_template_id' => isset($template['addon_id']) ? (int)$template['addon_id'] : 0,

View file

@ -268,6 +268,77 @@
<xs:element name="file" type="files_type" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>
<!-- Canonical Workshop / Server Content capability declaration.
The Server Content Manager (addonsmanager) reads this block to build
safe Workshop manifests for the agents. Keep customer-supplied Workshop
item IDs out of XML; users enter those through the Panel UI. -->
<xs:simpleType name="boolean_flag_type">
<xs:restriction base="xs:string">
<xs:enumeration value="0" />
<xs:enumeration value="1" />
<xs:enumeration value="false" />
<xs:enumeration value="true" />
<xs:enumeration value="no" />
<xs:enumeration value="yes" />
<xs:enumeration value="off" />
<xs:enumeration value="on" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="workshop_provider_type">
<xs:restriction base="xs:string">
<xs:enumeration value="steam" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="workshop_download_method_type">
<xs:restriction base="xs:string">
<xs:enumeration value="steamcmd" />
<xs:enumeration value="game_managed_workshop" />
<xs:enumeration value="manual" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="workshop_install_strategy_type">
<xs:restriction base="xs:string">
<xs:enumeration value="game_managed_workshop" />
<xs:enumeration value="steamcmd_download_only" />
<xs:enumeration value="copy_to_game_root" />
<xs:enumeration value="copy_to_mod_folder" />
<xs:enumeration value="dayz_mod_folder" />
<xs:enumeration value="arma_mod_folder" />
<xs:enumeration value="config_only" />
<xs:enumeration value="custom_scripted_install" />
</xs:restriction>
</xs:simpleType>
<xs:complexType name="workshop_copy_keys_type">
<xs:sequence>
<xs:element name="source_pattern" type="xs:string" minOccurs="0" />
<xs:element name="target_path" type="xs:string" minOccurs="0" />
</xs:sequence>
<xs:attribute name="enabled" type="boolean_flag_type" use="optional" />
</xs:complexType>
<xs:complexType name="workshop_support_type">
<xs:sequence>
<xs:element name="enabled" type="boolean_flag_type" minOccurs="0" />
<xs:element name="provider" type="workshop_provider_type" minOccurs="0" />
<xs:element name="steam_app_id" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="workshop_app_id" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="download_method" type="workshop_download_method_type" minOccurs="0" />
<xs:element name="install_strategy" type="workshop_install_strategy_type" minOccurs="0" />
<xs:element name="install_path" type="xs:string" minOccurs="0" />
<xs:element name="startup_param_format" type="xs:string" minOccurs="0" />
<xs:element name="mod_separator" type="xs:string" minOccurs="0" />
<xs:element name="mod_prefix" type="xs:string" minOccurs="0" />
<xs:element name="mod_folder_format" type="xs:string" minOccurs="0" />
<xs:element name="copy_keys" type="workshop_copy_keys_type" minOccurs="0" />
<xs:element name="script_linux" type="xs:string" minOccurs="0" />
<xs:element name="script_windows" type="xs:string" minOccurs="0" />
</xs:sequence>
</xs:complexType>
<!-- The main of the template -->
<xs:complexType name="server_config_type">
@ -278,6 +349,7 @@
<xs:element name="gameq_query_name" type="nonEmptyString" minOccurs="0" />
<xs:element name="installer" type="nonEmptyString" minOccurs="0" />
<xs:element name="game_name" type="nonEmptyString" />
<xs:element name="workshop_support" type="workshop_support_type" minOccurs="0" />
<xs:element name="server_exec_name" type="nonEmptyString" />
<xs:element name="query_port" type="query_port_type" minOccurs="0" />
<xs:element name="cli_template" type="nonEmptyString" minOccurs="0" />

View file

@ -2,7 +2,22 @@
<game_key>arma3_linux64</game_key>
<installer>steamcmd</installer>
<game_name>Arma 3</game_name>
<workshop_app_id>107410</workshop_app_id>
<workshop_support>
<enabled>1</enabled>
<provider>steam</provider>
<steam_app_id>107410</steam_app_id>
<workshop_app_id>107410</workshop_app_id>
<download_method>steamcmd</download_method>
<install_strategy>arma_mod_folder</install_strategy>
<install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path>
<startup_param_format>-mod={MOD_LIST}</startup_param_format>
<mod_separator>;</mod_separator>
<mod_prefix>@</mod_prefix>
<copy_keys enabled="1">
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
</workshop_support>
<server_exec_name>arma3server_x64</server_exec_name>
<cli_template>%CONFIG% %CFG% %PROFILES% %NAME% %IP% %PORT% %PLAYERS% %MODLIST% %SERVERMODLIST% %AUTOINIT%</cli_template>
<cli_params>

View file

@ -2,7 +2,22 @@
<game_key>arma3_win64</game_key>
<installer>steamcmd</installer>
<game_name>Arma 3</game_name>
<workshop_app_id>107410</workshop_app_id>
<workshop_support>
<enabled>1</enabled>
<provider>steam</provider>
<steam_app_id>107410</steam_app_id>
<workshop_app_id>107410</workshop_app_id>
<download_method>steamcmd</download_method>
<install_strategy>arma_mod_folder</install_strategy>
<install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path>
<startup_param_format>-mod={MOD_LIST}</startup_param_format>
<mod_separator>;</mod_separator>
<mod_prefix>@</mod_prefix>
<copy_keys enabled="1">
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
</workshop_support>
<server_exec_name>arma3server.exe</server_exec_name>
<cli_template>-profiles=profile -name=player -config=profile\server.cfg -cfg=profile\basic.cfg %PORT% %PLAYERS% %RANKING% %AUTOINIT% %DEBUG% %MODS% %SERVERMODS%</cli_template>
<cli_params>

View file

@ -0,0 +1,60 @@
<?php
/*
* Validates all game server XML configuration files against the bundled
* schema. This is a developer smoke test; it does not load the full Panel.
*/
$baseDir = dirname(__DIR__);
$schema = $baseDir . '/schema_server_config.xml';
$configDir = $baseDir . '/server_configs';
if (!is_file($schema)) {
fwrite(STDERR, "Schema not found: {$schema}\n");
exit(1);
}
$files = glob($configDir . '/*.xml');
sort($files);
if (empty($files)) {
fwrite(STDERR, "No XML files found under: {$configDir}\n");
exit(1);
}
libxml_use_internal_errors(true);
$failed = 0;
foreach ($files as $file) {
$doc = new DOMDocument();
$doc->preserveWhiteSpace = false;
$doc->formatOutput = false;
if (!$doc->load($file)) {
$failed++;
echo "FAIL " . basename($file) . "\n";
foreach (libxml_get_errors() as $error) {
echo " XML parse: " . trim($error->message) . "\n";
}
libxml_clear_errors();
continue;
}
if (!$doc->schemaValidate($schema)) {
$failed++;
echo "FAIL " . basename($file) . "\n";
foreach (libxml_get_errors() as $error) {
echo " Schema: " . trim($error->message) . "\n";
}
libxml_clear_errors();
continue;
}
echo "PASS " . basename($file) . "\n";
}
if ($failed > 0) {
echo "XML validation failed for {$failed} file(s).\n";
exit(1);
}
echo "All server config XML files validated successfully.\n";

View file

@ -1,364 +0,0 @@
<?php
/*
* GSP Steam Workshop: Admin profile management
* Copyright (C) 2025 WDS / GameServerPanel
*/
require_once __DIR__ . '/includes/functions.php';
if (!defined('SERVER_CONFIG_LOCATION')) {
require_once __DIR__ . '/../../config_games/server_config_parser.php';
}
function exec_ogp_module()
{
global $db;
echo '<h2>Steam Workshop &ndash; Admin</h2>';
sw_admin_print_styles();
$action = $_GET['action'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_profile'])) {
sw_admin_save_profile($db);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['sync_profiles'])) {
$n = sw_sync_profiles($db);
sw_success("Sync complete. $n new profile(s) created.");
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['detect_defaults'])) {
$profile = sw_get_profile_by_id($db, (int)($_POST['id'] ?? 0));
if (!$profile) {
sw_error('Profile not found.');
sw_admin_list($db);
return;
}
$detected = sw_detect_profile_defaults_from_xml($profile['config_name']);
if (empty($detected)) {
sw_error('No Steam defaults were detected in this game XML. You can still enter values manually.');
} else {
sw_success('Detected XML defaults. Review and apply when ready.');
}
sw_admin_edit_form($profile, $detected, true);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['apply_detected_defaults'])) {
$profile = sw_get_profile_by_id($db, (int)($_POST['id'] ?? 0));
if (!$profile) {
sw_error('Profile not found.');
sw_admin_list($db);
return;
}
$detected = sw_detect_profile_defaults_from_xml($profile['config_name']);
if (empty($detected)) {
sw_error('No Steam defaults were detected in this game XML.');
sw_admin_edit_form($profile);
return;
}
$overwrite = isset($_POST['overwrite_existing']) && $_POST['overwrite_existing'] === '1';
$updated = sw_apply_detected_profile_defaults($db, $profile, $detected, $overwrite);
if ($updated > 0) {
$overwriteMessage = $overwrite
? ' Existing values were allowed to be overwritten.'
: ' Existing non-empty values were kept.';
sw_success("Applied $updated detected default value(s)." . $overwriteMessage);
} else {
sw_success('No profile values needed updating based on current overwrite setting.');
}
$profile = sw_get_profile_by_id($db, (int)$profile['id']);
sw_admin_edit_form($profile, $detected, true);
return;
}
if ($action === 'edit' && isset($_GET['id'])) {
$profile = sw_get_profile_by_id($db, (int)$_GET['id']);
if ($profile) {
sw_admin_edit_form($profile);
} else {
sw_error('Profile not found.');
sw_admin_list($db);
}
return;
}
sw_admin_list($db);
}
function sw_admin_save_profile($db)
{
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
if (!$id) {
sw_error('Invalid profile ID.');
sw_admin_list($db);
return;
}
$profile = sw_get_profile_by_id($db, $id);
if (!$profile) {
sw_error('Profile not found.');
sw_admin_list($db);
return;
}
$fields = array(
'enabled' => isset($_POST['enabled']) ? 1 : 0,
'steam_app_id' => trim($_POST['steam_app_id'] ?? ''),
'workshop_app_id' => trim($_POST['workshop_app_id'] ?? ''),
'steam_login_required' => isset($_POST['steam_login_required']) ? 1 : 0,
'steamcmd_login_mode' => (($_POST['steamcmd_login_mode'] ?? 'anonymous') === 'account') ? 'account' : 'anonymous',
'steamcmd_path' => trim($_POST['steamcmd_path'] ?? ''),
'workshop_download_dir_template' => trim($_POST['workshop_download_dir_template'] ?? ''),
'server_root_template' => trim($_POST['server_root_template'] ?? ''),
'install_path_template' => trim($_POST['install_path_template'] ?? ''),
'folder_naming_format' => trim($_POST['folder_naming_format'] ?? ''),
'mod_launch_param_template' => trim($_POST['mod_launch_param_template'] ?? '-mod='),
'servermod_launch_param_template' => trim($_POST['servermod_launch_param_template'] ?? '-serverMod='),
'install_script_template' => trim($_POST['install_script_template'] ?? ''),
'update_script_template' => trim($_POST['update_script_template'] ?? ''),
'copy_bikeys_enabled' => isset($_POST['copy_bikeys_enabled']) ? 1 : 0,
'notes' => trim($_POST['notes'] ?? ''),
);
// Per-profile default behavior fields
$valid_update_modes = array('manual', 'on_restart', 'before_start');
$valid_restart_behaviors = array('none', 'if_stopped');
$posted_um = $_POST['default_update_mode'] ?? 'manual';
$posted_rb = $_POST['default_restart_behavior'] ?? 'none';
$fields['default_update_mode'] = in_array($posted_um, $valid_update_modes, true) ? $posted_um : 'manual';
$fields['default_restart_behavior'] = in_array($posted_rb, $valid_restart_behaviors, true) ? $posted_rb : 'none';
$setParts = array();
foreach ($fields as $col => $val) {
$setParts[] = "`$col` = '" . $db->realEscapeSingle($val) . "'";
}
$setParts[] = "`updated_at` = NOW()";
$ok = $db->query(
"UPDATE " . sw_table('steam_workshop_game_profiles') . "
SET " . implode(', ', $setParts) . "
WHERE `id` = $id LIMIT 1"
);
if ($ok) {
sw_success('Profile saved.');
} else {
sw_error('Failed to save profile.');
}
$profile = sw_get_profile_by_id($db, $id);
if ($profile) {
sw_admin_edit_form($profile);
} else {
sw_admin_list($db);
}
}
function sw_admin_list($db)
{
$profiles = sw_get_profiles($db);
?>
<div class="sw-admin-panel">
<p class="sw-muted">
Profiles map game XML configs to Steam Workshop defaults. Sync to create missing profiles, then edit each profile for game-specific paths and launch templates.
</p>
<form method="post" style="margin-bottom:12px;">
<button type="submit" name="sync_profiles" value="1" class="button"
onclick="return confirm('Sync workshop profiles from all game config XMLs?');">Sync Profiles from XML Configs</button>
</form>
<?php if (empty($profiles)): ?>
<p>No profiles yet. Click <em>Sync Profiles</em> to create them from installed game configs.</p>
<?php else: ?>
<table class="sw-admin-table" width="100%">
<thead>
<tr>
<th>Config Name</th>
<th>Game Name</th>
<th style="text-align:center;">Steam App ID</th>
<th style="text-align:center;">Workshop App ID</th>
<th style="text-align:center;">Enabled</th>
<th style="text-align:center;">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($profiles as $p): ?>
<tr>
<td><code><?= sw_h($p['config_name']) ?></code></td>
<td><?= sw_h($p['game_name']) ?></td>
<td style="text-align:center;"><?= sw_h($p['steam_app_id']) ?></td>
<td style="text-align:center;"><?= sw_h($p['workshop_app_id']) ?></td>
<td style="text-align:center;"><?= $p['enabled'] ? '<span class="sw-state-on">Yes</span>' : '<span class="sw-state-off">No</span>' ?></td>
<td style="text-align:center;"><a class="button small" href="home.php?m=steam_workshop&p=admin&action=edit&id=<?= (int)$p['id'] ?>">Edit</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
}
function sw_admin_edit_form(array $profile, array $detected = array(), $showDetectedBox = false)
{
$id = (int)$profile['id'];
?>
<p><a href="home.php?m=steam_workshop&p=admin">&laquo; Back to profile list</a></p>
<h3>Edit Profile: <?= sw_h($profile['config_name']) ?> &ndash; <?= sw_h($profile['game_name']) ?></h3>
<div class="sw-admin-panel">
<div class="sw-note">
<strong>Placeholder tokens:</strong>
<code>{SERVER_ROOT}</code> <code>{HOME_ID}</code> <code>{STEAM_APP_ID}</code> <code>{WORKSHOP_APP_ID}</code> <code>{MOD_FOLDER}</code>
</div>
<div class="sw-section">
<h4>XML-Assisted Defaults</h4>
<p class="sw-muted">Use values detected from this game XML. Existing values are not overwritten unless you explicitly allow it.</p>
<form method="post" action="home.php?m=steam_workshop&p=admin&action=edit&id=<?= $id ?>" style="display:inline-block; margin-right:8px;">
<input type="hidden" name="id" value="<?= $id ?>">
<button type="submit" name="detect_defaults" value="1" class="button">Detect from XML</button>
</form>
<?php if ($showDetectedBox && !empty($detected)): ?>
<div class="sw-detected-box">
<strong>Detected values:</strong>
<ul>
<li>Steam App ID: <code><?= sw_h($detected['steam_app_id'] ?? '') ?></code></li>
<li>Workshop App ID: <code><?= sw_h($detected['workshop_app_id'] ?? '') ?></code></li>
<li>SteamCMD path: <code><?= sw_h($detected['steamcmd_path'] ?? '') ?></code></li>
<li>Workshop download dir: <code><?= sw_h($detected['workshop_download_dir_template'] ?? '') ?></code></li>
<li>Server root: <code><?= sw_h($detected['server_root_template'] ?? '') ?></code></li>
<li>Mod install path: <code><?= sw_h($detected['install_path_template'] ?? '') ?></code></li>
</ul>
<form method="post" action="home.php?m=steam_workshop&p=admin&action=edit&id=<?= $id ?>">
<input type="hidden" name="id" value="<?= $id ?>">
<label style="display:block;margin-bottom:8px;">
<input type="checkbox" name="overwrite_existing" value="1">
Allow overwrite of existing non-empty values.
</label>
<button type="submit" name="apply_detected_defaults" value="1" class="button">Refresh defaults from XML</button>
</form>
</div>
<?php endif; ?>
</div>
<form method="post" action="home.php?m=steam_workshop&p=admin&action=edit&id=<?= $id ?>">
<input type="hidden" name="id" value="<?= $id ?>">
<div class="sw-section">
<h4>Global Profile Defaults</h4>
<div class="sw-grid">
<label><span>Enabled</span><input type="checkbox" name="enabled" value="1" <?= $profile['enabled'] ? 'checked' : '' ?>></label>
<label><span>Steam App ID</span><input type="text" name="steam_app_id" value="<?= sw_h($profile['steam_app_id']) ?>" placeholder="Detected from XML when available"></label>
<label><span>Workshop App ID</span><input type="text" name="workshop_app_id" value="<?= sw_h($profile['workshop_app_id']) ?>" placeholder="Detected from XML when available"></label>
<label><span>SteamCMD Path</span><input type="text" name="steamcmd_path" value="<?= sw_h($profile['steamcmd_path']) ?>" placeholder="/home/gameserver/steamcmd/steamcmd.sh"></label>
<label><span>Steam Login Required</span><input type="checkbox" name="steam_login_required" value="1" <?= $profile['steam_login_required'] ? 'checked' : '' ?>></label>
<label><span>SteamCMD Login Mode</span>
<select name="steamcmd_login_mode">
<option value="anonymous" <?= $profile['steamcmd_login_mode'] === 'anonymous' ? 'selected' : '' ?>>anonymous</option>
<option value="account" <?= $profile['steamcmd_login_mode'] === 'account' ? 'selected' : '' ?>>account</option>
</select>
</label>
</div>
</div>
<div class="sw-section">
<h4>Path Templates</h4>
<p class="sw-muted">Use placeholders so paths stay portable between server homes.</p>
<div class="sw-grid">
<label><span>Workshop Download Directory</span><input type="text" name="workshop_download_dir_template" value="<?= sw_h($profile['workshop_download_dir_template']) ?>" placeholder="{SERVER_ROOT}/steamapps/workshop/content/{WORKSHOP_APP_ID}"></label>
<label><span>Server Root</span><input type="text" name="server_root_template" value="<?= sw_h($profile['server_root_template']) ?>" placeholder="{SERVER_ROOT}"></label>
<label><span>Mod Install Path</span><input type="text" name="install_path_template" value="<?= sw_h($profile['install_path_template']) ?>" placeholder="{SERVER_ROOT}/{MOD_FOLDER}"></label>
</div>
</div>
<div class="sw-section">
<h4>Per-Game Runtime Values</h4>
<div class="sw-grid">
<label><span>Folder Naming Format</span><input type="text" name="folder_naming_format" value="<?= sw_h($profile['folder_naming_format']) ?>" placeholder="@{MOD_NAME}"></label>
<label><span>Client Launch Param</span><input type="text" name="mod_launch_param_template" value="<?= sw_h($profile['mod_launch_param_template']) ?>" placeholder="-mod="></label>
<label><span>Server Launch Param</span><input type="text" name="servermod_launch_param_template" value="<?= sw_h($profile['servermod_launch_param_template']) ?>" placeholder="-serverMod="></label>
<label><span>Copy .bikey files</span><input type="checkbox" name="copy_bikeys_enabled" value="1" <?= $profile['copy_bikeys_enabled'] ? 'checked' : '' ?>></label>
</div>
</div>
<div class="sw-section">
<h4>Optional Script Templates</h4>
<label><span>Install Script Template</span><textarea name="install_script_template" rows="6"><?= sw_h($profile['install_script_template']) ?></textarea></label>
<label><span>Update Script Template</span><textarea name="update_script_template" rows="6"><?= sw_h($profile['update_script_template']) ?></textarea></label>
</div>
<div class="sw-section">
<h4>Notes</h4>
<label><textarea name="notes" rows="4"><?= sw_h($profile['notes']) ?></textarea></label>
</div>
<div class="sw-section">
<h4>Default Workshop Behavior for New Servers</h4>
<p class="sw-muted">
These defaults are applied when a user enables Workshop on a server that has no saved behavior settings yet.
Users can always override them on their own server pages.
All defaults are intentionally set to the safest option (manual / no automatic restart).
</p>
<div class="sw-grid">
<label>
<span>Default Install / Update Mode</span>
<select name="default_update_mode">
<option value="manual" <?= (($profile['default_update_mode'] ?? 'manual') === 'manual') ? 'selected' : '' ?>>Manual only (safe default)</option>
<option value="on_restart" <?= (($profile['default_update_mode'] ?? 'manual') === 'on_restart') ? 'selected' : '' ?>>On next restart</option>
<option value="before_start" <?= (($profile['default_update_mode'] ?? 'manual') === 'before_start') ? 'selected' : '' ?>>Before every server start</option>
</select>
</label>
<label>
<span>Default Restart Behavior</span>
<select name="default_restart_behavior">
<option value="none" <?= (($profile['default_restart_behavior'] ?? 'none') === 'none') ? 'selected' : '' ?>>Do not restart automatically (safe default)</option>
<option value="if_stopped" <?= (($profile['default_restart_behavior'] ?? 'none') === 'if_stopped') ? 'selected' : '' ?>>Restart only if server is stopped</option>
</select>
</label>
</div>
</div>
<p>
<button type="submit" name="save_profile" value="1" class="button">Save Profile</button>
<a href="home.php?m=steam_workshop&p=admin" class="button">Cancel</a>
</p>
</form>
</div>
<?php
}
function sw_admin_print_styles()
{
static $printed = false;
if ($printed) {
return;
}
$printed = true;
echo '<style>
.sw-admin-panel{background:#171717;border:1px solid #2d2d2d;border-radius:6px;padding:14px;margin:10px 0;color:#e7e7e7}
.sw-admin-table{border-collapse:collapse;background:#121212}
.sw-admin-table th,.sw-admin-table td{border:1px solid #2c2c2c;padding:8px}
.sw-admin-table thead th{background:#232323;color:#fff}
.sw-state-on{color:#78d978;font-weight:700}
.sw-state-off{color:#9a9a9a}
.sw-section{margin-top:14px;padding:12px;border:1px solid #2f2f2f;border-radius:4px;background:#111}
.sw-section h4{margin:0 0 8px 0;color:#f6f6f6}
.sw-note{margin-bottom:10px;background:#202020;border-left:3px solid #3f80d0;padding:10px}
.sw-muted{color:#b3b3b3}
.sw-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}
.sw-grid label, .sw-section > label{display:block}
.sw-grid span, .sw-section span{display:block;font-size:12px;color:#bdbdbd;margin-bottom:4px}
.sw-grid input[type=text], .sw-grid select, .sw-section textarea{width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #3a3a3a;color:#eee;padding:7px;border-radius:4px}
.sw-grid input[type=checkbox]{transform:scale(1.1);margin-top:4px}
.sw-detected-box{margin-top:10px;padding:10px;background:#1d2a1d;border:1px solid #335933;border-radius:4px}
.sw-detected-box ul{margin:8px 0 10px 18px}
.sw-detected-box code,.sw-note code{background:#0b0b0b;padding:1px 4px;border-radius:3px;color:#9fd4ff}
</style>';
}

View file

@ -1,569 +0,0 @@
<?php
/*
* GSP Steam Workshop: Agent CLI update script
* Copyright (C) 2025 WDS / GameServerPanel
*
* This file must only be run from the command line (CLI).
* Do NOT expose it through a web server.
*
* Usage:
* php agent_update_workshop.php --home-id=123
* php agent_update_workshop.php --all
*
* The script connects to the panel database, reads the list of enabled mods
* for the specified server(s), downloads/updates each mod via SteamCMD,
* copies mod folders into the server root, copies .bikey files into the
* server keys/ directory, and updates the install status in the database.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
// ── Safety: CLI only ──────────────────────────────────────────────────────
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("This script may only be run from the command line.\n");
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
$panel_root = realpath(__DIR__ . '/../../..');
if (!$panel_root || !is_dir($panel_root)) {
fwrite(STDERR, "ERROR: Cannot locate panel root from " . __DIR__ . "\n");
exit(1);
}
$config_file = $panel_root . '/includes/config.inc.php';
if (!is_file($config_file)) {
fwrite(STDERR, "ERROR: Panel config not found: $config_file\n");
fwrite(STDERR, " Copy includes/config.inc.php.example to includes/config.inc.php and set credentials.\n");
exit(1);
}
require_once $config_file;
require_once $panel_root . '/includes/helpers.php';
require_once $panel_root . '/includes/database_mysqli.php';
require_once __DIR__ . '/includes/functions.php';
// ── Database connection ───────────────────────────────────────────────────
// Variables $db_host, $db_user, $db_pass, $db_name, $table_prefix, $db_type,
// $db_port come from config.inc.php (loaded above).
$db = createDatabaseConnection(
$db_type,
$db_host,
$db_user,
$db_pass,
$db_name,
$table_prefix,
isset($db_port) ? $db_port : null
);
if (!is_object($db)) {
$error_text = '';
get_db_error_text($db, $error_text);
fwrite(STDERR, "ERROR: Database connection failed: $error_text\n");
exit(1);
}
// ── Argument parsing ──────────────────────────────────────────────────────
$opts = getopt('', array('home-id:', 'all', 'dry-run'));
$do_all = array_key_exists('all', $opts);
$dry_run = array_key_exists('dry-run', $opts);
$target_id = isset($opts['home-id']) ? (int)$opts['home-id'] : 0;
if (!$do_all && !$target_id) {
fwrite(STDERR, "Usage:\n");
fwrite(STDERR, " php agent_update_workshop.php --home-id=123\n");
fwrite(STDERR, " php agent_update_workshop.php --all\n");
fwrite(STDERR, " Add --dry-run to simulate without running SteamCMD or copying files.\n");
exit(1);
}
if ($dry_run) {
echo "[DRY RUN] No files will be modified and SteamCMD will not be called.\n";
}
// ── Collect home IDs to process ───────────────────────────────────────────
if ($do_all) {
// Find all home_ids that have at least one enabled queued mod with an enabled profile.
$rows = $db->resultQuery(
"SELECT DISTINCT m.home_id
FROM " . sw_table('steam_workshop_server_mods') . " m
JOIN " . sw_table('steam_workshop_game_profiles') . " p ON p.id = m.profile_id
WHERE m.enabled = 1 AND p.enabled = 1 AND m.install_status = 'queued'"
);
$home_ids = $rows ? array_column($rows, 'home_id') : array();
} else {
$home_ids = array($target_id);
}
if (empty($home_ids)) {
echo "No servers with enabled Workshop mods found.\n";
exit(0);
}
$overall_success = true;
foreach ($home_ids as $home_id) {
$home_id = (int)$home_id;
echo "\n=== Processing home_id=$home_id ===\n";
$ok = sw_agent_process_home($db, $home_id, $dry_run);
if (!$ok) {
$overall_success = false;
echo " [WARN] One or more errors occurred for home_id=$home_id.\n";
}
}
exit($overall_success ? 0 : 1);
// ─────────────────────────────────────────────────────────────────────────
// Core logic
// ─────────────────────────────────────────────────────────────────────────
/**
* Process all enabled mods for one server home.
*
* @param OGPDatabase $db
* @param int $home_id
* @param bool $dry_run
* @return bool true if all mods processed without errors
*/
function sw_agent_process_home($db, $home_id, $dry_run)
{
// Load server info
$home = sw_get_home_info($db, $home_id);
if (!$home) {
echo " [ERROR] Server home $home_id not found in database.\n";
return false;
}
echo " Server: " . $home['home_name'] . " (game: " . $home['game_name'] . ")\n";
echo " Path: " . $home['home_path'] . "\n";
// Resolve Workshop profile via game config key
$profile = sw_get_profile_for_home($db, $home_id);
if (!$profile) {
echo " [SKIP] No enabled Workshop profile for this game type.\n";
return true;
}
echo " Profile: " . $profile['config_name'] . " (workshop_app_id=" . $profile['workshop_app_id'] . ")\n";
// Build common template variables
$server_root = sw_apply_template(
$profile['server_root_template'] ?: $home['home_path'],
sw_agent_tpl_vars($home, $profile)
);
$server_root = rtrim($server_root, '/');
// Load queued+enabled mods, sorted by sort_order
$mods = sw_agent_get_queued_mods($db, $home_id);
if (empty($mods)) {
echo " No queued Workshop updates for this server.\n";
return true;
}
$keys_dir = $server_root . '/keys';
if (!$dry_run && !is_dir($keys_dir)) {
@mkdir($keys_dir, 0755, true);
}
$all_ok = true;
foreach ($mods as $mod) {
$mod_id = (int)$mod['id'];
$workshop_id = $mod['workshop_id'];
echo "\n Mod: " . ($mod['mod_name'] ?: $workshop_id) . " [ID=$workshop_id]\n";
// Mark as updating
if (!$dry_run) {
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'updating', `last_error` = NULL, `updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
// Build template vars for this mod
$folder_name = $mod['folder_name'] ?: ('@' . $workshop_id);
$tpl_vars = array_merge(
sw_agent_tpl_vars($home, $profile),
array(
'WORKSHOP_ID' => $workshop_id,
'MOD_NAME' => $mod['mod_name'] ?: $workshop_id,
'FOLDER_NAME' => $folder_name,
'MOD_FOLDER' => $folder_name,
'SERVER_ROOT' => $server_root,
'WORKSHOP_DOWNLOAD_DIR' => sw_apply_template(
$profile['workshop_download_dir_template']
?: ($server_root . '/steamapps/workshop/content/' . $profile['workshop_app_id']),
array(
'SERVER_ROOT' => $server_root,
'WORKSHOP_APP_ID' => $profile['workshop_app_id'],
'HOME_ID' => $home['home_id'],
)
),
)
);
$download_dir = $tpl_vars['WORKSHOP_DOWNLOAD_DIR'];
$mod_cache = rtrim($download_dir, '/') . '/' . $workshop_id;
// 1. Download / update via SteamCMD
$cmd_result = sw_agent_steamcmd_download($mod, $profile, $tpl_vars, $dry_run);
if (!$cmd_result['ok']) {
$err = $cmd_result['error'];
echo " [ERROR] SteamCMD failed: $err\n";
if (!$dry_run) {
$safe_err = $db->realEscapeSingle($err);
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'failed', `last_error` = '$safe_err', `updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
$all_ok = false;
continue;
}
// 2. Copy / sync mod folder to server root
$install_path = sw_apply_template(
$profile['install_path_template'] ?: ($server_root . '/{MOD_FOLDER}'),
$tpl_vars
);
$copy_ok = sw_agent_copy_mod($mod_cache, $install_path, $dry_run);
if (!$copy_ok) {
$err = "Failed to copy mod from $mod_cache to $install_path";
echo " [ERROR] $err\n";
if (!$dry_run) {
$safe_err = $db->realEscapeSingle($err);
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'failed', `last_error` = '$safe_err', `updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
$all_ok = false;
continue;
}
// 3. Copy .bikey files to server keys/ directory
if (!empty($profile['copy_bikeys_enabled'])) {
sw_agent_copy_bikeys($install_path, $keys_dir, $dry_run);
}
// 4. Mark as installed
if (!$dry_run) {
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'installed',
`last_installed_at` = NOW(),
`last_updated_at` = NOW(),
`last_error` = NULL,
`updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
echo " [OK] Installed → $install_path\n";
}
return $all_ok;
}
function sw_agent_get_queued_mods($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id
AND `enabled` = 1
AND `install_status` = 'queued'
ORDER BY `sort_order` ASC, `id` ASC"
);
return $rows ? $rows : array();
}
/**
* Build the standard template variable map for a given home + profile.
*
* @param array $home
* @param array $profile
* @return array
*/
function sw_agent_tpl_vars(array $home, array $profile)
{
return array(
'HOME_ID' => $home['home_id'],
'SERVER_ID' => $home['home_id'],
'REMOTE_SERVER_ID' => $home['remote_server_id'],
'GAME_NAME' => $home['game_name'],
'CONFIG_NAME' => $home['game_key'],
'STEAM_APP_ID' => $profile['steam_app_id'],
'WORKSHOP_APP_ID' => $profile['workshop_app_id'],
'STEAMCMD_PATH' => $profile['steamcmd_path'],
'SERVER_ROOT' => $home['home_path'],
'INSTALL_PATH' => $home['home_path'],
);
}
/**
* Run SteamCMD to download / update a single Workshop item.
*
* Uses the profile's update_script_template if set; otherwise falls back to
* a standard anonymous or authenticated +workshop_download_item invocation.
*
* @param array $mod
* @param array $profile
* @param array $tpl_vars
* @param bool $dry_run
* @return array ['ok' => bool, 'error' => string]
*/
function sw_agent_steamcmd_download(array $mod, array $profile, array $tpl_vars, $dry_run)
{
$steamcmd = $profile['steamcmd_path'] ?: '/home/gameserver/steamcmd/steamcmd.sh';
$workshop_id = $mod['workshop_id'];
$app_id = $profile['workshop_app_id'];
$dl_dir = $tpl_vars['WORKSHOP_DOWNLOAD_DIR'];
if (!empty($profile['update_script_template'])) {
// Admin has provided a custom update script.
$script_body = sw_apply_template($profile['update_script_template'], $tpl_vars);
return sw_agent_run_script($script_body, $dry_run);
}
// Build default SteamCMD command.
// +force_install_dir is set to the parent of the workshop content so that
// SteamCMD places files in <dl_dir>/<workshop_id>/.
$parent_dir = dirname($dl_dir); // .../steamapps/workshop/content
if ($profile['steamcmd_login_mode'] === 'account') {
// When account login is required the operator must supply credentials
// in the update_script_template. We cannot safely store a password here.
return array(
'ok' => false,
'error' => "Account login is required for this profile but no update_script_template is set. "
. "Add a custom update_script_template in the admin profile that includes SteamCMD login credentials.",
);
}
// Validate that steamcmd exists
if (!$dry_run) {
if (!is_file($steamcmd)) {
return array('ok' => false, 'error' => "SteamCMD not found: $steamcmd");
}
if (!is_executable($steamcmd)) {
return array('ok' => false, 'error' => "SteamCMD is not executable: $steamcmd");
}
}
// Build argument list; escape each argument individually.
$args = array(
escapeshellarg($steamcmd),
'+force_install_dir', escapeshellarg($parent_dir),
'+login', 'anonymous',
'+workshop_download_item', escapeshellarg($app_id), escapeshellarg($workshop_id),
'+quit',
);
$cmd = implode(' ', $args);
echo " SteamCMD: $cmd\n";
if ($dry_run) {
echo " [DRY RUN] Skipping SteamCMD execution.\n";
return array('ok' => true, 'error' => '');
}
$output = array();
$return_var = 0;
exec($cmd . ' 2>&1', $output, $return_var);
foreach ($output as $line) {
echo " $line\n";
}
if ($return_var !== 0) {
return array(
'ok' => false,
'error' => "SteamCMD exited with code $return_var. " . implode(' ', array_slice($output, -3)),
);
}
return array('ok' => true, 'error' => '');
}
/**
* Execute a shell script body.
* The script is written to a temporary file and executed with /bin/sh.
*
* @param string $script_body
* @param bool $dry_run
* @return array ['ok' => bool, 'error' => string]
*/
function sw_agent_run_script($script_body, $dry_run)
{
if ($dry_run) {
echo " [DRY RUN] Would execute script:\n";
foreach (explode("\n", $script_body) as $line) {
echo " $line\n";
}
return array('ok' => true, 'error' => '');
}
$tmp = tempnam(sys_get_temp_dir(), 'sw_agent_');
if (!$tmp) {
return array('ok' => false, 'error' => 'Could not create temporary file for script.');
}
file_put_contents($tmp, "#!/bin/sh\nset -e\n" . $script_body);
chmod($tmp, 0700);
$output = array();
$return_var = 0;
exec('/bin/sh ' . escapeshellarg($tmp) . ' 2>&1', $output, $return_var);
@unlink($tmp);
foreach ($output as $line) {
echo " $line\n";
}
if ($return_var !== 0) {
return array(
'ok' => false,
'error' => "Script exited with code $return_var. " . implode(' ', array_slice($output, -3)),
);
}
return array('ok' => true, 'error' => '');
}
/**
* Copy (rsync-style) the downloaded mod folder into the server root.
* Uses rsync when available, falls back to recursive PHP copy.
*
* @param string $src Downloaded mod folder (e.g. .../content/221100/2863534533)
* @param string $dst Target path in server root (e.g. /servers/123/@CF)
* @param bool $dry_run
* @return bool
*/
function sw_agent_copy_mod($src, $dst, $dry_run)
{
echo " Copy: $src$dst\n";
if (!$dry_run && !is_dir($src)) {
echo " [WARN] Source directory not found: $src\n";
return false;
}
if ($dry_run) {
return true;
}
if (!is_dir($dst)) {
if (!@mkdir($dst, 0755, true)) {
return false;
}
}
// Try rsync first (preserves permissions, handles deletes cleanly).
if (sw_agent_cmd_exists('rsync')) {
$cmd = 'rsync -a --delete '
. escapeshellarg(rtrim($src, '/') . '/') . ' '
. escapeshellarg(rtrim($dst, '/') . '/') . ' 2>&1';
exec($cmd, $out, $ret);
if ($ret === 0) {
return true;
}
echo " [WARN] rsync failed (exit $ret); falling back to PHP copy.\n";
}
// PHP recursive copy fallback.
return sw_agent_recursive_copy($src, $dst);
}
/**
* Copy all .bikey files found recursively under $mod_dir/keys/ into $keys_dir.
*
* @param string $mod_dir Installed mod directory
* @param string $keys_dir Server keys directory
* @param bool $dry_run
* @return void
*/
function sw_agent_copy_bikeys($mod_dir, $keys_dir, $dry_run)
{
// Search in common key locations within the mod folder.
$search_dirs = array(
$mod_dir . '/keys',
$mod_dir . '/Keys',
$mod_dir . '/key',
$mod_dir . '/Key',
);
$found = 0;
foreach ($search_dirs as $kdir) {
if (!is_dir($kdir)) {
continue;
}
foreach (glob($kdir . '/*.bikey') as $bikey) {
$target = $keys_dir . '/' . basename($bikey);
echo " .bikey: " . basename($bikey) . "$keys_dir/\n";
if (!$dry_run) {
@copy($bikey, $target);
}
$found++;
}
}
if ($found === 0) {
echo " (no .bikey files found in mod keys/ folder)\n";
}
}
/**
* Return true if a command is available in PATH.
*
* @param string $cmd
* @return bool
*/
function sw_agent_cmd_exists($cmd)
{
$which = trim((string)shell_exec('which ' . escapeshellarg($cmd) . ' 2>/dev/null'));
return !empty($which);
}
/**
* Recursively copy directory $src into $dst (creating $dst if needed).
*
* @param string $src
* @param string $dst
* @return bool
*/
function sw_agent_recursive_copy($src, $dst)
{
$dir = @opendir($src);
if (!$dir) {
return false;
}
if (!is_dir($dst)) {
@mkdir($dst, 0755, true);
}
while (false !== ($file = readdir($dir))) {
if ($file === '.' || $file === '..') {
continue;
}
$s = $src . '/' . $file;
$d = $dst . '/' . $file;
if (is_dir($s)) {
sw_agent_recursive_copy($s, $d);
} else {
copy($s, $d);
}
}
closedir($dir);
return true;
}

View file

@ -1,652 +0,0 @@
<?php
/*
* GSP Steam Workshop: shared helper functions
* Copyright (C) 2025 WDS / GameServerPanel
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
function sw_db_prefix()
{
if (defined('DB_PREFIX') && DB_PREFIX !== '') {
return DB_PREFIX;
}
if (isset($GLOBALS['db_prefix']) && $GLOBALS['db_prefix'] !== '') {
return $GLOBALS['db_prefix'];
}
if (isset($GLOBALS['table_prefix']) && $GLOBALS['table_prefix'] !== '') {
return $GLOBALS['table_prefix'];
}
return 'gsp_';
}
function sw_table($tableName)
{
return '`' . sw_db_prefix() . $tableName . '`';
}
// ── Profile helpers ───────────────────────────────────────────────────────
/**
* Return all rows from steam_workshop_game_profiles ordered by game_name.
*
* @param OGPDatabase $db
* @return array|false
*/
function sw_get_profiles($db)
{
return $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_game_profiles') . "
ORDER BY `game_name` ASC, `config_name` ASC"
);
}
/**
* Return a single profile row by primary key.
*
* @param OGPDatabase $db
* @param int $id
* @return array|false
*/
function sw_get_profile_by_id($db, $id)
{
$id = (int)$id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_game_profiles') . "
WHERE `id` = $id LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
/**
* Return a single profile row by config_name (= game_key from XML).
*
* @param OGPDatabase $db
* @param string $config_name
* @return array|false
*/
function sw_get_profile_by_config_name($db, $config_name)
{
$safe = $db->realEscapeSingle($config_name);
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_game_profiles') . "
WHERE `config_name` = '$safe' LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
/**
* Return the Workshop profile that applies to a server home.
* Resolves: home_id config_homes.game_key workshop profile.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array|false profile row or false when none found / not enabled
*/
function sw_get_profile_for_home($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT p.*
FROM " . sw_table('steam_workshop_game_profiles') . " p
JOIN " . sw_table('config_homes') . " c
ON c.`game_key` = p.`config_name`
JOIN " . sw_table('server_homes') . " s
ON s.`home_cfg_id` = c.`home_cfg_id`
WHERE s.`home_id` = $home_id
AND p.`enabled` = 1
LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
// ── Mod helpers ───────────────────────────────────────────────────────────
/**
* Return all mods for a server, sorted by sort_order ASC.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array|false
*/
function sw_get_server_mods($db, $home_id)
{
$home_id = (int)$home_id;
return $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id
ORDER BY `sort_order` ASC, `id` ASC"
);
}
/**
* Return a single mod row by primary key.
*
* @param OGPDatabase $db
* @param int $id
* @return array|false
*/
function sw_get_mod_by_id($db, $id)
{
$id = (int)$id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `id` = $id LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
// ── Server / ownership helpers ────────────────────────────────────────────
/**
* Return server_homes row joined with config_homes and remote_servers
* for the given home_id, or false when not found.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array|false
*/
function sw_get_home_info($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT s.*, c.`game_key`, c.`game_name`, r.`agent_ip`, r.`agent_port`
FROM " . sw_table('server_homes') . " s
JOIN " . sw_table('config_homes') . " c ON c.`home_cfg_id` = s.`home_cfg_id`
JOIN " . sw_table('remote_servers') . " r ON r.`remote_server_id` = s.`remote_server_id`
WHERE s.`home_id` = $home_id LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
/**
* Verify that the current session user is allowed to manage this home.
* Admins always pass. Regular users/subusers must have an entry in
* user_homes (or be the user_id_main).
*
* @param OGPDatabase $db
* @param int $user_id
* @param int $home_id
* @return bool
*/
function sw_user_owns_home($db, $user_id, $home_id)
{
if (!isset($_SESSION['users_group'])) {
return false;
}
if ($_SESSION['users_group'] === 'admin') {
return true;
}
$user_id = (int)$user_id;
$home_id = (int)$home_id;
// Direct owner
$rows = $db->resultQuery(
"SELECT 1 FROM " . sw_table('server_homes') . "
WHERE `home_id` = $home_id AND `user_id_main` = $user_id LIMIT 1"
);
if ($rows) {
return true;
}
// Assigned via user_homes
$rows = $db->resultQuery(
"SELECT 1 FROM " . sw_table('user_homes') . "
WHERE `home_id` = $home_id AND `user_id` = $user_id LIMIT 1"
);
if ($rows) {
return true;
}
// Assigned via group
$rows = $db->resultQuery(
"SELECT 1 FROM " . sw_table('user_group_homes') . " ugh
JOIN " . sw_table('user_groups') . " ug ON ug.`group_id` = ugh.`group_id`
WHERE ugh.`home_id` = $home_id AND ug.`user_id` = $user_id LIMIT 1"
);
return (bool)$rows;
}
// ── Game-config helpers ───────────────────────────────────────────────────
/**
* Return an array of all game configs from the XML files.
* Each element is a SimpleXMLElement (game_config root node).
*
* @return SimpleXMLElement[]
*/
function sw_get_all_game_configs()
{
if (!defined('SERVER_CONFIG_LOCATION')) {
// server_config_parser.php defines this; load it if not already done.
if (file_exists(__DIR__ . '/../../config_games/server_config_parser.php')) {
require_once __DIR__ . '/../../config_games/server_config_parser.php';
} else {
return array();
}
}
$configs = array();
foreach (glob(SERVER_CONFIG_LOCATION . '*.xml') as $file) {
$xml = read_server_config($file);
if ($xml !== false) {
$configs[] = $xml;
}
}
return $configs;
}
/**
* Ensure every game config has a matching row in steam_workshop_game_profiles.
* Only creates rows that are missing; never overwrites existing data.
*
* @param OGPDatabase $db
* @return int number of new rows inserted
*/
function sw_sync_profiles($db)
{
$configs = sw_get_all_game_configs();
$created = 0;
foreach ($configs as $xml) {
$config_name = (string)$xml->game_key;
$game_name = (string)$xml->game_name;
if (empty($config_name)) {
continue;
}
$existing = sw_get_profile_by_config_name($db, $config_name);
if ($existing) {
continue; // already have a profile for this game config
}
$safe_config = $db->realEscapeSingle($config_name);
$safe_name = $db->realEscapeSingle($game_name);
$ok = $db->query("INSERT IGNORE INTO " . sw_table('steam_workshop_game_profiles') . " (`config_name`, `game_name`, `enabled`) VALUES ('$safe_config', '$safe_name', 0)");
if ($ok) {
$created++;
}
}
return $created;
}
// ── Template / launch-param helpers ─────────────────────────────────────
/**
* Replace {PLACEHOLDER} tokens in $template with values from $vars.
* Unknown tokens are left intact so admins can spot missing values.
*
* @param string $template
* @param array $vars associative: 'PLACEHOLDER' => 'value'
* @return string
*/
function sw_apply_template($template, array $vars)
{
$search = array();
$replace = array();
foreach ($vars as $key => $value) {
$search[] = '{' . $key . '}';
$replace[] = (string)$value;
}
return str_replace($search, $replace, $template);
}
/**
* Build the -mod= and -serverMod= launch parameter strings from an ordered
* list of enabled mods and the game profile.
*
* Returns an associative array:
* 'mod' => '-mod=@Mod1;@Mod2' (client mods)
* 'servermod' => '-serverMod=@ServerOnly' (server-side mods)
* 'combined' => '-mod=... -serverMod=...' (ready-to-paste)
*
* @param array $mods rows from steam_workshop_server_mods (must be pre-filtered
* for enabled = 1 and sorted by sort_order)
* @param array $profile row from steam_workshop_game_profiles
* @return array
*/
function sw_generate_launch_params(array $mods, array $profile)
{
$mod_param = trim($profile['mod_launch_param_template'] ?? '-mod=');
$servermod_param = trim($profile['servermod_launch_param_template'] ?? '-serverMod=');
$client_folders = array();
$server_folders = array();
foreach ($mods as $mod) {
if (empty($mod['enabled'])) {
continue;
}
$folder = !empty($mod['folder_name']) ? $mod['folder_name'] : ('@' . $mod['workshop_id']);
if ($mod['mod_type'] === 'server') {
$server_folders[] = $folder;
} else {
$client_folders[] = $folder;
}
}
$mod_str = $client_folders ? ($mod_param . implode(';', $client_folders)) : '';
$servermod_str = $server_folders ? ($servermod_param . implode(';', $server_folders)) : '';
$combined = trim($mod_str . ' ' . $servermod_str);
return array(
'mod' => $mod_str,
'servermod' => $servermod_str,
'combined' => $combined,
);
}
// ── Server behavior settings helpers ─────────────────────────────────────
/**
* Return the workshop behavior settings row for a home, or an array of
* safe defaults when no row exists yet.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array
*/
function sw_get_server_settings($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_settings') . "
WHERE `home_id` = $home_id LIMIT 1"
);
if ($rows && isset($rows[0]) && is_array($rows[0])) {
$settings = $rows[0];
// Runtime normalization is kept as a fallback for legacy/manual rows that
// were not updated via module migrations.
$legacyUpdateMap = array(
'scheduled' => 'manual',
);
$legacyRestartMap = array(
'if_empty' => 'if_stopped',
'next_restart' => 'if_stopped',
'immediate' => 'none',
);
$legacyScheduleMap = array(
'hourly' => 'daily',
);
if (isset($legacyUpdateMap[$settings['update_mode'] ?? ''])) {
$settings['update_mode'] = $legacyUpdateMap[$settings['update_mode']];
}
if (isset($legacyRestartMap[$settings['restart_behavior'] ?? ''])) {
$settings['restart_behavior'] = $legacyRestartMap[$settings['restart_behavior']];
}
if (isset($legacyScheduleMap[$settings['schedule_interval'] ?? ''])) {
$settings['schedule_interval'] = $legacyScheduleMap[$settings['schedule_interval']];
}
$validUpdateModes = array('manual', 'on_restart', 'before_start');
$validRestartBehaviors = array('none', 'if_stopped');
$validIntervals = array('disabled', 'daily', 'weekly');
if (!in_array($settings['update_mode'] ?? '', $validUpdateModes, true)) {
$settings['update_mode'] = 'manual';
}
if (!in_array($settings['restart_behavior'] ?? '', $validRestartBehaviors, true)) {
$settings['restart_behavior'] = 'none';
}
if (!in_array($settings['schedule_interval'] ?? '', $validIntervals, true)) {
$settings['schedule_interval'] = 'disabled';
}
return $settings;
}
// Safe defaults manual only, no automatic restarts, schedule disabled
return array(
'home_id' => $home_id,
'update_mode' => 'manual',
'restart_behavior' => 'none',
'schedule_interval' => 'disabled',
);
}
/**
* Upsert the workshop behavior settings for a server home.
*
* @param OGPDatabase $db
* @param int $home_id
* @param array $data keys: update_mode, restart_behavior, schedule_interval
* @return bool
*/
function sw_save_server_settings($db, $home_id, array $data)
{
$home_id = (int)$home_id;
$valid_update_modes = array('manual', 'on_restart', 'before_start');
$valid_restart_behaviors = array('none', 'if_stopped');
$valid_intervals = array('disabled', 'daily', 'weekly');
$update_mode = in_array($data['update_mode'] ?? '', $valid_update_modes, true) ? $data['update_mode'] : 'manual';
$restart_behavior = in_array($data['restart_behavior'] ?? '', $valid_restart_behaviors, true) ? $data['restart_behavior'] : 'none';
$schedule_interval = in_array($data['schedule_interval'] ?? '', $valid_intervals, true) ? $data['schedule_interval'] : 'disabled';
$safe_um = $db->realEscapeSingle($update_mode);
$safe_rb = $db->realEscapeSingle($restart_behavior);
$safe_si = $db->realEscapeSingle($schedule_interval);
return (bool)$db->query(
"INSERT INTO " . sw_table('steam_workshop_server_settings') . "
(`home_id`, `update_mode`, `restart_behavior`, `hot_load`,
`warning_minutes`, `schedule_interval`, `created_at`, `updated_at`)
VALUES ($home_id, '$safe_um', '$safe_rb', 'disabled',
0, '$safe_si', NOW(), NOW())
ON DUPLICATE KEY UPDATE
`update_mode` = '$safe_um',
`restart_behavior` = '$safe_rb',
`hot_load` = 'disabled',
`warning_minutes` = 0,
`schedule_interval` = '$safe_si',
`updated_at` = NOW()"
);
}
// ── Output helpers ────────────────────────────────────────────────────────
/**
* Render a short inline success banner.
*
* @param string $msg
* @return void
*/
function sw_success($msg)
{
echo '<div style="background:#d4edda;border:1px solid #c3e6cb;color:#155724;padding:8px 12px;margin:8px 0;border-radius:4px;">'
. htmlspecialchars($msg, ENT_QUOTES, 'UTF-8') . '</div>';
}
/**
* Render a short inline error banner.
*
* @param string $msg
* @return void
*/
function sw_error($msg)
{
echo '<div style="background:#f8d7da;border:1px solid #f5c6cb;color:#721c24;padding:8px 12px;margin:8px 0;border-radius:4px;">'
. htmlspecialchars($msg, ENT_QUOTES, 'UTF-8') . '</div>';
}
/**
* Escape a value for HTML output.
*
* @param mixed $v
* @return string
*/
function sw_h($v)
{
return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
}
function sw_detect_profile_defaults_from_xml($configName)
{
$configName = trim((string)$configName);
if ($configName === '') {
return array();
}
$matched = null;
foreach (sw_get_all_game_configs() as $xml) {
if ((string)$xml->game_key === $configName) {
$matched = $xml;
break;
}
}
if (!$matched) {
return array();
}
$steamAppId = '';
if (isset($matched->mods->mod)) {
foreach ($matched->mods->mod as $mod) {
$candidate = trim((string)$mod->installer_name);
if ($candidate !== '' && preg_match('/^\d+$/', $candidate)) {
$steamAppId = $candidate;
break;
}
}
}
$workshopAppId = '';
foreach (array('workshop_app_id', 'workshop_appid', 'steam_workshop_app_id', 'steam_workshop_appid') as $tag) {
if (isset($matched->$tag)) {
$candidate = trim((string)$matched->$tag);
if ($candidate !== '' && preg_match('/^\d+$/', $candidate)) {
$workshopAppId = $candidate;
break;
}
}
}
$xmlBlob = $matched->asXML();
if ($workshopAppId === '' && $xmlBlob !== false && preg_match('/steamapps\/workshop\/content\/(\d+)/i', $xmlBlob, $m)) {
$workshopAppId = $m[1];
}
if ($workshopAppId === '') {
$workshopAppId = $steamAppId;
}
$gameKey = strtolower(trim((string)$matched->game_key));
$gameName = strtolower(trim((string)$matched->game_name));
$installPathTemplate = (strpos($gameKey . ' ' . $gameName, 'arma') !== false || strpos($gameKey . ' ' . $gameName, 'dayz') !== false)
? '{SERVER_ROOT}/{MOD_FOLDER}'
: '{SERVER_ROOT}/workshop/{MOD_FOLDER}';
return array(
'steam_app_id' => $steamAppId,
'workshop_app_id' => $workshopAppId,
'steamcmd_path' => '/home/gameserver/steamcmd/steamcmd.sh',
'server_root_template' => '{SERVER_ROOT}',
'workshop_download_dir_template' => '{SERVER_ROOT}/steamapps/workshop/content/{WORKSHOP_APP_ID}',
'install_path_template' => $installPathTemplate,
);
}
function sw_apply_detected_profile_defaults($db, array $profile, array $detected, $overwriteExisting = false)
{
$columns = array(
'steam_app_id',
'workshop_app_id',
'steamcmd_path',
'workshop_download_dir_template',
'server_root_template',
'install_path_template',
);
$setParts = array();
$updated = 0;
foreach ($columns as $column) {
if (!array_key_exists($column, $detected) || $detected[$column] === '') {
continue;
}
$current = trim((string)($profile[$column] ?? ''));
if (!$overwriteExisting && $current !== '') {
continue;
}
if ($current === $detected[$column]) {
continue;
}
$setParts[] = "`$column` = '" . $db->realEscapeSingle($detected[$column]) . "'";
$updated++;
}
if (empty($setParts)) {
return 0;
}
$setParts[] = "`updated_at` = NOW()";
$db->query(
"UPDATE " . sw_table('steam_workshop_game_profiles') . "
SET " . implode(', ', $setParts) . "
WHERE `id` = " . (int)$profile['id'] . " LIMIT 1"
);
return $updated;
}
function steam_workshop_resolve_paths($db, array $home_info, $workshop_id, $target_path_template = '', $optional_folder_name = '', $workshop_app_id_override = '')
{
$profile = sw_get_profile_for_home($db, (int)$home_info['home_id']);
if (!$profile || !is_array($profile)) {
return array('ok' => false, 'error' => 'No enabled Steam Workshop profile for this server home.');
}
$workshop_id = trim((string)$workshop_id);
if ($workshop_id === '' || !preg_match('/^[0-9]+$/', $workshop_id)) {
return array('ok' => false, 'error' => 'Invalid Workshop ID.');
}
$workshop_app_id = trim((string)$workshop_app_id_override);
if ($workshop_app_id === '') {
$workshop_app_id = (string)$profile['workshop_app_id'];
}
$folder_name = trim((string)$optional_folder_name);
if ($folder_name === '') {
$folder_name = '@' . $workshop_id;
}
$vars = array(
'HOME_ID' => (int)$home_info['home_id'],
'SERVER_ROOT' => rtrim((string)$home_info['home_path'], '/'),
'GAME_ROOT' => rtrim((string)$home_info['home_path'], '/'),
'WORKSHOP_ID' => $workshop_id,
'WORKSHOP_APP_ID' => $workshop_app_id,
'STEAM_APP_ID' => (string)$profile['steam_app_id'],
'FOLDER_NAME' => $folder_name,
'MOD_FOLDER' => $folder_name,
);
$target_template = trim((string)$target_path_template);
if ($target_template === '') {
$target_template = !empty($profile['install_path_template']) ? (string)$profile['install_path_template'] : '{SERVER_ROOT}/workshop/{MOD_FOLDER}';
}
$resolved_target = sw_apply_template($target_template, $vars);
return array(
'ok' => true,
'profile' => $profile,
'workshop_id' => $workshop_id,
'workshop_app_id' => $workshop_app_id,
'steam_app_id' => (string)$profile['steam_app_id'],
'folder_name' => $folder_name,
'target_path_template' => $target_template,
'target_path_resolved' => $resolved_target,
'vars' => $vars,
);
}
function steam_workshop_download_item($db, array $home_info, $workshop_id, $target_path_template = '', array $options = array())
{
$optional_folder_name = isset($options['optional_folder_name']) ? $options['optional_folder_name'] : '';
$workshop_app_id = isset($options['workshop_app_id']) ? $options['workshop_app_id'] : '';
return steam_workshop_resolve_paths($db, $home_info, $workshop_id, $target_path_template, $optional_folder_name, $workshop_app_id);
}
function steam_workshop_install_item_to_home($db, array $home_info, $workshop_id, $target_path_template = '', array $options = array())
{
return steam_workshop_download_item($db, $home_info, $workshop_id, $target_path_template, $options);
}

View file

@ -1,89 +0,0 @@
-- GSP Steam Workshop Manual SQL Reference
-- =========================================
-- Replace PREFIX_ with your actual table prefix (e.g. gsp_).
-- Compatible with MySQL 5.7 and MySQL 8.0.
-- Do NOT hardcode any database name here.
-- Run in the panel database.
-- ── Drop legacy tables (if upgrading from the old adapter-based implementation) ──
DROP TABLE IF EXISTS `PREFIX_workshop_game_profiles`;
DROP TABLE IF EXISTS `PREFIX_workshop_cache`;
DROP TABLE IF EXISTS `PREFIX_server_workshop_mods`;
DROP TABLE IF EXISTS `PREFIX_server_workshop_settings`;
-- ── Create new tables ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `PREFIX_steam_workshop_game_profiles` (
`id` INT NOT NULL AUTO_INCREMENT,
`config_name` VARCHAR(100) NOT NULL,
`game_name` VARCHAR(255) NOT NULL DEFAULT '',
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`steam_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`workshop_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`steam_login_required` TINYINT(1) NOT NULL DEFAULT 0,
`steamcmd_login_mode` ENUM('anonymous','account') NOT NULL DEFAULT 'anonymous',
`steamcmd_path` VARCHAR(512) NOT NULL DEFAULT '/home/gameserver/steamcmd/steamcmd.sh',
`workshop_download_dir_template` TEXT NULL,
`server_root_template` TEXT NULL,
`install_path_template` TEXT NULL,
`folder_naming_format` VARCHAR(64) NOT NULL DEFAULT '@{MOD_NAME}',
`mod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-mod=',
`servermod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-serverMod=',
`install_script_template` TEXT NULL,
`update_script_template` TEXT NULL,
`copy_bikeys_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`notes` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_config_name` (`config_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `PREFIX_steam_workshop_server_mods` (
`id` INT NOT NULL AUTO_INCREMENT,
`home_id` INT NOT NULL,
`profile_id` INT NOT NULL,
`workshop_id` VARCHAR(64) NOT NULL,
`mod_name` VARCHAR(255) NOT NULL DEFAULT '',
`folder_name` VARCHAR(255) NOT NULL DEFAULT '',
`mod_type` ENUM('client','server') NOT NULL DEFAULT 'client',
`sort_order` INT NOT NULL DEFAULT 0,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`install_status` VARCHAR(32) NOT NULL DEFAULT '',
`last_installed_at` DATETIME NULL,
`last_updated_at` DATETIME NULL,
`last_error` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_home_workshop` (`home_id`, `workshop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── Example: DayZ profile ────────────────────────────────────────────────
-- After running the above, insert an example DayZ profile.
-- Adjust config_name to match your actual DayZ game_key from config_homes.
-- (Run `SELECT game_key, game_name FROM PREFIX_config_homes WHERE game_name LIKE '%DayZ%';`
-- to find the right config_name.)
--
-- INSERT INTO `PREFIX_steam_workshop_game_profiles`
-- (`config_name`, `game_name`, `enabled`,
-- `steam_app_id`, `workshop_app_id`,
-- `steamcmd_path`,
-- `workshop_download_dir_template`,
-- `server_root_template`,
-- `install_path_template`,
-- `folder_naming_format`,
-- `mod_launch_param_template`,
-- `servermod_launch_param_template`,
-- `copy_bikeys_enabled`)
-- VALUES
-- ('dayz_win64', 'DayZ', 1,
-- '223350', '221100',
-- '/home/gameserver/steamcmd/steamcmd.sh',
-- '{SERVER_ROOT}/steamapps/workshop/content/{WORKSHOP_APP_ID}',
-- '/home/gameserver/servers/{HOME_ID}',
-- '{SERVER_ROOT}/{MOD_FOLDER}',
-- '@{MOD_NAME}',
-- '-mod=',
-- '-serverMod=',
-- 1);

View file

@ -1,248 +0,0 @@
<?php
/*
* GSP Steam Workshop module
* Copyright (C) 2025 WDS / GameServerPanel
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
$module_title = "Steam Workshop";
$module_version = "3.3";
$db_version = 5;
$module_required = FALSE;
// DEPRECATED: The Steam Workshop standalone module has been superseded by
// Server Content Manager (addonsmanager). Navigation access is removed so
// users are directed to the new unified workshop workflow. The DB tables
// and helper functions are preserved for backward compatibility.
// See Panel/modules/addonsmanager/ for the replacement implementation.
$module_menus = array();
if (!function_exists('sw_module_db_prefix')) {
function sw_module_db_prefix()
{
if (defined('DB_PREFIX') && DB_PREFIX !== '') {
return DB_PREFIX;
}
if (isset($GLOBALS['db_prefix']) && $GLOBALS['db_prefix'] !== '') {
return $GLOBALS['db_prefix'];
}
if (isset($GLOBALS['table_prefix']) && $GLOBALS['table_prefix'] !== '') {
return $GLOBALS['table_prefix'];
}
return 'gsp_';
}
}
if (!function_exists('sw_module_table_name')) {
function sw_module_table_name($table)
{
$prefix = preg_replace('/[^a-zA-Z0-9_]/', '', (string)sw_module_db_prefix());
$name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$table);
if ($prefix === '') {
$prefix = 'gsp_';
}
return $prefix . $name;
}
}
if (!function_exists('sw_module_table')) {
function sw_module_table($table)
{
return '`' . sw_module_table_name($table) . '`';
}
}
$install_queries = array();
$legacyDrops = array(
"DROP TABLE IF EXISTS " . sw_module_table('workshop_game_profiles'),
"DROP TABLE IF EXISTS " . sw_module_table('workshop_cache'),
"DROP TABLE IF EXISTS " . sw_module_table('server_workshop_mods'),
"DROP TABLE IF EXISTS " . sw_module_table('server_workshop_settings'),
);
$schemaCreate = array(
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_game_profiles') . " (
`id` INT NOT NULL AUTO_INCREMENT,
`config_name` VARCHAR(100) NOT NULL,
`game_name` VARCHAR(255) NOT NULL DEFAULT '',
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`steam_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`workshop_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`steam_login_required` TINYINT(1) NOT NULL DEFAULT 0,
`steamcmd_login_mode` ENUM('anonymous','account') NOT NULL DEFAULT 'anonymous',
`steamcmd_path` VARCHAR(512) NOT NULL DEFAULT '/home/gameserver/steamcmd/steamcmd.sh',
`workshop_download_dir_template` TEXT NULL,
`server_root_template` TEXT NULL,
`install_path_template` TEXT NULL,
`folder_naming_format` VARCHAR(64) NOT NULL DEFAULT '@{MOD_NAME}',
`mod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-mod=',
`servermod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-serverMod=',
`install_script_template` TEXT NULL,
`update_script_template` TEXT NULL,
`copy_bikeys_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`notes` TEXT NULL,
`default_update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual',
`default_restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none',
`default_hot_load` ENUM('disabled','attempt') NOT NULL DEFAULT 'disabled',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_config_name` (`config_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_server_mods') . " (
`id` INT NOT NULL AUTO_INCREMENT,
`home_id` INT NOT NULL,
`profile_id` INT NOT NULL,
`workshop_id` VARCHAR(64) NOT NULL,
`mod_name` VARCHAR(255) NOT NULL DEFAULT '',
`folder_name` VARCHAR(255) NOT NULL DEFAULT '',
`mod_type` ENUM('client','server') NOT NULL DEFAULT 'client',
`sort_order` INT NOT NULL DEFAULT 0,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`install_status` VARCHAR(32) NOT NULL DEFAULT '',
`last_installed_at` DATETIME NULL,
`last_updated_at` DATETIME NULL,
`last_error` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_home_workshop` (`home_id`, `workshop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_server_settings') . " (
`home_id` INT NOT NULL,
`update_mode` ENUM('manual','on_restart','before_start')
NOT NULL DEFAULT 'manual',
`restart_behavior` ENUM('none','if_stopped')
NOT NULL DEFAULT 'none',
`hot_load` ENUM('disabled','attempt')
NOT NULL DEFAULT 'disabled',
`warning_minutes` INT NOT NULL DEFAULT 0,
`schedule_interval` ENUM('disabled','daily','weekly') NOT NULL DEFAULT 'disabled',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`home_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
);
$install_queries[0] = array_merge($legacyDrops, $schemaCreate);
$install_queries[3] = array_merge($legacyDrops, $schemaCreate);
$install_queries[4] = array();
$install_queries[5] = array(
function ($db) {
$table = sw_module_table_name('steam_workshop_server_settings');
$exists = $db->resultQuery(
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . $db->realEscapeSingle($table) . "'"
);
if (!$exists || (int)($exists[0]['cnt'] ?? 0) === 0) {
return (bool)$db->query(
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_server_settings') . " (
`home_id` INT NOT NULL,
`update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual',
`restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none',
`hot_load` ENUM('disabled','attempt') NOT NULL DEFAULT 'disabled',
`warning_minutes` INT NOT NULL DEFAULT 0,
`schedule_interval` ENUM('disabled','daily','weekly') NOT NULL DEFAULT 'disabled',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`home_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
$ok = true;
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `update_mode` = CASE
WHEN `update_mode` = 'scheduled' THEN 'manual'
ELSE `update_mode`
END"
);
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `restart_behavior` = CASE
WHEN `restart_behavior` IN ('if_empty','next_restart') THEN 'if_stopped'
WHEN `restart_behavior` = 'immediate' THEN 'none'
ELSE `restart_behavior`
END"
);
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `schedule_interval` = CASE
WHEN `schedule_interval` = 'hourly' THEN 'daily'
WHEN `schedule_interval` IS NULL OR `schedule_interval` = '' THEN 'disabled'
ELSE `schedule_interval`
END"
);
// The simplified workflow intentionally hard-disables these legacy fields
// for every row so old unsupported behaviors cannot be re-enabled.
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `hot_load` = 'disabled', `warning_minutes` = 0"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_server_settings') . "
MODIFY `update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual'"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_server_settings') . "
MODIFY `restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none'"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_server_settings') . "
MODIFY `schedule_interval` ENUM('disabled','daily','weekly') NOT NULL DEFAULT 'disabled'"
);
return $ok;
},
function ($db) {
$profileTable = sw_module_table_name('steam_workshop_game_profiles');
$exists = $db->resultQuery(
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . $db->realEscapeSingle($profileTable) . "'"
);
if (!$exists || (int)($exists[0]['cnt'] ?? 0) === 0) {
return true;
}
$ok = true;
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_game_profiles') . "
SET `default_update_mode` = CASE
WHEN `default_update_mode` = 'scheduled' THEN 'manual'
ELSE `default_update_mode`
END"
);
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_game_profiles') . "
SET `default_restart_behavior` = CASE
WHEN `default_restart_behavior` IN ('if_empty','next_restart') THEN 'if_stopped'
WHEN `default_restart_behavior` = 'immediate' THEN 'none'
ELSE `default_restart_behavior`
END"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_game_profiles') . "
MODIFY `default_update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual'"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_game_profiles') . "
MODIFY `default_restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none'"
);
return $ok;
},
);
$uninstall_queries = array(
"DROP TABLE IF EXISTS " . sw_module_table('steam_workshop_server_settings'),
"DROP TABLE IF EXISTS " . sw_module_table('steam_workshop_server_mods'),
"DROP TABLE IF EXISTS " . sw_module_table('steam_workshop_game_profiles'),
);

View file

@ -1,44 +0,0 @@
<?php
/*
* GSP Steam Workshop: monitor page button
* Copyright (C) 2025 WDS / GameServerPanel
*
* Adds a "Steam Workshop" button to the game/server monitor page when:
* - the game's Workshop profile is enabled in steam_workshop_game_profiles, AND
* - the current user owns the server or is an admin.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
$module_buttons = array();
if (!function_exists('sw_monitor_db_prefix')) {
function sw_monitor_db_prefix()
{
if (defined('DB_PREFIX') && DB_PREFIX !== '') {
return DB_PREFIX;
}
if (isset($GLOBALS['db_prefix']) && $GLOBALS['db_prefix'] !== '') {
return $GLOBALS['db_prefix'];
}
if (isset($GLOBALS['table_prefix']) && $GLOBALS['table_prefix'] !== '') {
return $GLOBALS['table_prefix'];
}
return 'gsp_';
}
}
if (!function_exists('sw_monitor_table')) {
function sw_monitor_table($name)
{
return '`' . sw_monitor_db_prefix() . $name . '`';
}
}
// Deprecated: the standalone steam_workshop workflow is no longer the primary
// user path. Server Content Manager (addonsmanager) now owns Workshop installs
// and exposes its own monitor button when content templates exist.
?>

View file

@ -1,6 +0,0 @@
<navigation>
<!-- Admin: manage Steam Workshop game profiles -->
<page key="admin" file="admin.php" access="admin" />
<!-- User: manage per-server mods -->
<page key="user" file="user.php" access="admin,user,subuser" />
</navigation>

View file

@ -1,558 +0,0 @@
<?php
/*
* GSP Steam Workshop: User mod management
* Copyright (C) 2025 WDS / GameServerPanel
*
* Accessible via: home.php?m=steam_workshop&p=user&home_id=123
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
require_once __DIR__ . '/includes/functions.php';
function exec_ogp_module()
{
global $db;
echo '<h2>Steam Workshop Mod Manager</h2>';
$home_id = isset($_REQUEST['home_id']) ? (int)$_REQUEST['home_id'] : 0;
if (!$home_id) {
sw_error('No server selected. Please access this page from your game server manager.');
return;
}
// Ownership check
if (!sw_user_owns_home($db, (int)$_SESSION['user_id'], $home_id)) {
sw_error('Access denied. You do not own this server.');
return;
}
// Load server info
$home = sw_get_home_info($db, $home_id);
if (!$home) {
sw_error('Server not found.');
return;
}
// Find matching Workshop profile
$profile = sw_get_profile_for_home($db, $home_id);
if (!$profile) {
echo '<p>Steam Workshop is not enabled for this game.</p>';
echo '<p>An administrator must enable Workshop support for this game under '
. '<em>Steam Workshop &rsaquo; Admin</em>.</p>';
return;
}
$action = $_POST['action'] ?? ($_GET['action'] ?? '');
// ── POST handlers ─────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
switch ($action) {
case 'add_mod':
sw_user_add_mod($db, $home_id, $profile);
break;
case 'save_mod':
sw_user_save_mod($db, $home_id);
break;
case 'delete_mod':
sw_user_delete_mod($db, $home_id);
break;
case 'toggle_mod':
sw_user_toggle_mod($db, $home_id);
break;
case 'move_up':
case 'move_down':
sw_user_reorder_mod($db, $home_id, $action);
break;
case 'queue_update':
sw_user_queue_update($db, $home_id);
break;
case 'save_settings':
sw_user_save_settings($db, $home_id);
break;
}
}
// ── Render page ───────────────────────────────────────────────────
sw_user_render($db, $home_id, $home, $profile);
}
// ─────────────────────────────────────────────────────────────────────────
// POST action handlers
// ─────────────────────────────────────────────────────────────────────────
function sw_user_add_mod($db, $home_id, array $profile)
{
$workshop_id = trim($_POST['workshop_id'] ?? '');
if (!preg_match('/^\d{1,20}$/', $workshop_id)) {
sw_error('Invalid Workshop ID must be a numeric Steam Workshop item ID.');
return;
}
// Prevent duplicates
$safe_wid = $db->realEscapeSingle($workshop_id);
$exists = $db->resultQuery(
"SELECT id FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id AND `workshop_id` = '$safe_wid' LIMIT 1"
);
if ($exists) {
sw_error("Workshop ID $workshop_id is already in the list.");
return;
}
// Determine next sort_order
$last = $db->resultQuery(
"SELECT MAX(`sort_order`) AS m FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id"
);
$sort = ($last && isset($last[0]['m'])) ? ((int)$last[0]['m'] + 1) : 0;
$mod_name = $db->realEscapeSingle(trim($_POST['mod_name'] ?? ''));
$mod_type = (($_POST['mod_type'] ?? 'client') === 'server') ? 'server' : 'client';
$profile_id = (int)$profile['id'];
// Auto-generate folder name from naming format template
$folder_name = sw_apply_template(
$profile['folder_naming_format'],
array(
'MOD_NAME' => !empty($mod_name) ? $mod_name : $workshop_id,
'WORKSHOP_ID' => $workshop_id,
'WORKSHOP_APP_ID'=> $profile['workshop_app_id'],
)
);
$safe_fname = $db->realEscapeSingle($folder_name);
$safe_mname = $mod_name; // already escaped above via realEscapeSingle
$ok = $db->query(
"INSERT INTO " . sw_table('steam_workshop_server_mods') . "
(`home_id`, `profile_id`, `workshop_id`, `mod_name`, `folder_name`,
`mod_type`, `sort_order`, `enabled`, `install_status`, `created_at`)
VALUES ($home_id, $profile_id, '$safe_wid', '$safe_mname', '$safe_fname',
'$mod_type', $sort, 1, '', NOW())"
);
if ($ok) {
sw_success("Workshop mod $workshop_id added.");
} else {
sw_error('Failed to add mod.');
}
}
function sw_user_save_mod($db, $home_id)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
sw_error('Mod not found or access denied.');
return;
}
$mod_name = $db->realEscapeSingle(trim($_POST['mod_name'] ?? ''));
$folder_name = $db->realEscapeSingle(trim($_POST['folder_name'] ?? ''));
$mod_type = (($_POST['mod_type'] ?? 'client') === 'server') ? 'server' : 'client';
if (empty($folder_name)) {
sw_error('Folder name cannot be empty.');
return;
}
$ok = $db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `mod_name` = '$mod_name',
`folder_name` = '$folder_name',
`mod_type` = '$mod_type',
`updated_at` = NOW()
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
if ($ok) {
sw_success('Mod updated.');
} else {
sw_error('Failed to update mod.');
}
}
function sw_user_delete_mod($db, $home_id)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
sw_error('Mod not found or access denied.');
return;
}
$db->query(
"DELETE FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
sw_success('Mod removed from list.');
}
function sw_user_toggle_mod($db, $home_id)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
sw_error('Mod not found or access denied.');
return;
}
$new_state = $mod['enabled'] ? 0 : 1;
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `enabled` = $new_state, `updated_at` = NOW()
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
}
function sw_user_reorder_mod($db, $home_id, $direction)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
return;
}
$mods = sw_get_server_mods($db, $home_id);
if (!$mods) {
return;
}
// Normalise sort_order to 0-based sequential integers
$sorted = array_values($mods);
foreach ($sorted as $idx => $m) {
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `sort_order` = $idx
WHERE `id` = " . (int)$m['id'] . " AND `home_id` = $home_id LIMIT 1"
);
}
// Find the position of the target mod
$pos = -1;
foreach ($sorted as $idx => $m) {
if ((int)$m['id'] === $mod_id) {
$pos = $idx;
break;
}
}
if ($pos < 0) {
return;
}
if ($direction === 'move_up' && $pos > 0) {
$swap_pos = $pos - 1;
} elseif ($direction === 'move_down' && $pos < (count($sorted) - 1)) {
$swap_pos = $pos + 1;
} else {
return; // already at boundary
}
$swap_id = (int)$sorted[$swap_pos]['id'];
// Swap sort_order values
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `sort_order` = $swap_pos
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `sort_order` = $pos
WHERE `id` = $swap_id AND `home_id` = $home_id LIMIT 1"
);
}
function sw_user_queue_update($db, $home_id)
{
// Mark all enabled mods as 'queued' so the agent picks them up.
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'queued', `updated_at` = NOW()
WHERE `home_id` = $home_id AND `enabled` = 1"
);
sw_success('All enabled mods were queued. Updates are processed automatically by the server agent.');
}
function sw_user_save_settings($db, $home_id)
{
$ok = sw_save_server_settings($db, $home_id, array(
'update_mode' => $_POST['update_mode'] ?? 'manual',
'restart_behavior' => $_POST['restart_behavior'] ?? 'none',
'schedule_interval' => $_POST['schedule_interval'] ?? 'disabled',
));
if ($ok) {
sw_success('Workshop behavior settings saved.');
} else {
sw_error('Failed to save settings.');
}
}
// ─────────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────────
function sw_user_render($db, $home_id, array $home, array $profile)
{
$mods = sw_get_server_mods($db, $home_id) ?: array();
$settings = sw_get_server_settings($db, $home_id);
$queuedCount = 0;
$failedCount = 0;
$installedCount = 0;
$latestUpdateAt = '';
$latestError = '';
foreach ($mods as $mod) {
if (($mod['install_status'] ?? '') === 'queued') {
$queuedCount++;
} elseif (($mod['install_status'] ?? '') === 'failed') {
$failedCount++;
} elseif (($mod['install_status'] ?? '') === 'installed') {
$installedCount++;
}
if (!empty($mod['last_updated_at']) && $mod['last_updated_at'] > $latestUpdateAt) {
$latestUpdateAt = $mod['last_updated_at'];
}
if (($mod['install_status'] ?? '') === 'failed' && !empty($mod['last_error']) && $latestError === '') {
$latestError = $mod['last_error'];
}
}
$base_url = 'home.php?m=steam_workshop&p=user&home_id=' . $home_id;
?>
<style>
.sw-user-panel{background:#161616;border:1px solid #2f2f2f;border-radius:6px;padding:14px;margin:10px 0;color:#ececec}
.sw-user-panel h3{margin:0 0 10px 0;color:#fff}
.sw-user-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
.sw-user-grid label{display:block}
.sw-user-grid span{display:block;font-size:12px;color:#bebebe;margin-bottom:4px}
.sw-user-grid input[type=text],.sw-user-grid select{width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #3a3a3a;color:#f0f0f0;padding:7px;border-radius:4px}
.sw-user-table-wrap{overflow-x:auto}
.sw-user-table{width:100%;border-collapse:collapse;min-width:860px}
.sw-user-table th,.sw-user-table td{border:1px solid #353535;padding:8px;vertical-align:middle}
.sw-user-table th{background:#202020;text-align:left;color:#fff}
.sw-status-ok{color:#7cdc7c;font-weight:700}
.sw-status-queued{color:#ffca63;font-weight:700}
.sw-status-failed{color:#ff8484;font-weight:700}
.sw-status-progress{color:#8cc7ff;font-weight:700}
.sw-muted{color:#b4b4b4}
</style>
<div class="sw-user-panel">
<p>
<strong>Server:</strong> <?= sw_h($home['home_name']) ?>
&nbsp;&nbsp;<strong>Game:</strong> <?= sw_h($home['game_name']) ?>
&nbsp;&nbsp;<strong>Workshop Profile:</strong> <?= sw_h($profile['config_name']) ?>
</p>
<p class="sw-muted" style="margin-bottom:0;">
Queue updates from this page. The server agent applies queued updates automatically.
</p>
</div>
<div class="sw-user-panel">
<h3>Add Workshop Mod</h3>
<form method="post" action="<?= sw_h($base_url) ?>">
<input type="hidden" name="action" value="add_mod">
<div class="sw-user-grid">
<label>
<span>Workshop ID</span>
<input type="text" id="workshop_id" name="workshop_id" placeholder="e.g. 2863534533" required>
</label>
<label>
<span>Display Name (optional)</span>
<input type="text" id="add_mod_name" name="mod_name" placeholder="e.g. CF">
</label>
<label>
<span>Mod Type</span>
<select id="add_mod_type" name="mod_type">
<option value="client">Client mod</option>
<option value="server">Server-side only</option>
</select>
</label>
</div>
<p style="margin:12px 0 0;">
<button type="submit" class="button">Add Mod</button>
</p>
</form>
</div>
<div class="sw-user-panel">
<h3>Update Queue &amp; Last Result</h3>
<p>
<strong>Enabled mods:</strong> <?= count(array_filter($mods, function ($m) { return !empty($m['enabled']); })) ?>
&nbsp;&nbsp;<strong>Queued:</strong> <?= $queuedCount ?>
&nbsp;&nbsp;<strong>Installed:</strong> <?= $installedCount ?>
&nbsp;&nbsp;<strong>Failed:</strong> <?= $failedCount ?>
</p>
<p>
<strong>Last update time:</strong> <?= $latestUpdateAt ? sw_h($latestUpdateAt) : 'Never' ?>
</p>
<?php if ($latestError !== ''): ?>
<p><strong>Last error:</strong> <?= sw_h($latestError) ?></p>
<?php endif; ?>
<form method="post" action="<?= sw_h($base_url) ?>">
<input type="hidden" name="action" value="queue_update">
<button type="submit" class="button" onclick="return confirm('Queue all enabled mods for update?');">Queue Update for All Enabled Mods</button>
</form>
</div>
<div class="sw-user-panel">
<h3>Workshop Behavior Settings</h3>
<form method="post" action="<?= sw_h($base_url) ?>">
<input type="hidden" name="action" value="save_settings">
<div class="sw-user-grid">
<label>
<span>Update Mode</span>
<select name="update_mode">
<option value="manual" <?= ($settings['update_mode'] === 'manual') ? 'selected' : '' ?>>Manual only</option>
<option value="on_restart" <?= ($settings['update_mode'] === 'on_restart') ? 'selected' : '' ?>>On next restart</option>
<option value="before_start" <?= ($settings['update_mode'] === 'before_start') ? 'selected' : '' ?>>Before server start</option>
</select>
</label>
<label>
<span>Restart Behavior</span>
<select name="restart_behavior">
<option value="none" <?= ($settings['restart_behavior'] === 'none') ? 'selected' : '' ?>>Never restart automatically</option>
<option value="if_stopped" <?= ($settings['restart_behavior'] === 'if_stopped') ? 'selected' : '' ?>>Restart only if server is stopped</option>
</select>
</label>
<label>
<span>Scheduled Checks</span>
<select name="schedule_interval">
<option value="disabled" <?= ($settings['schedule_interval'] === 'disabled') ? 'selected' : '' ?>>Disabled</option>
<option value="daily" <?= ($settings['schedule_interval'] === 'daily') ? 'selected' : '' ?>>Daily</option>
<option value="weekly" <?= ($settings['schedule_interval'] === 'weekly') ? 'selected' : '' ?>>Weekly</option>
</select>
</label>
</div>
<p style="margin:12px 0 0;">
<button type="submit" class="button">Save Behavior Settings</button>
</p>
</form>
</div>
<div class="sw-user-panel">
<h3>Installed Mods (<?= count($mods) ?>)</h3>
<?php if (empty($mods)): ?>
<p>No mods added yet. Use the form above to add Workshop IDs.</p>
<?php else: ?>
<div class="sw-user-table-wrap">
<table class="sw-user-table">
<thead>
<tr>
<th>#</th>
<th>Workshop ID</th>
<th>Mod Name</th>
<th>Folder Name</th>
<th>Type</th>
<th>Enabled</th>
<th>Status</th>
<th>Last Update</th>
<th>Last Error</th>
<th>Order</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($mods as $idx => $mod): ?>
<?php
$shortError = '';
if (!empty($mod['last_error'])) {
$shortError = (strlen($mod['last_error']) > 70)
? (substr($mod['last_error'], 0, 67) . '...')
: $mod['last_error'];
}
?>
<tr style="<?= !$mod['enabled'] ? 'opacity:0.55;' : '' ?>">
<td><?= $idx + 1 ?></td>
<td style="font-family:monospace;"><?= sw_h($mod['workshop_id']) ?></td>
<td>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<input type="hidden" name="action" value="save_mod">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<input type="text" name="mod_name" value="<?= sw_h($mod['mod_name']) ?>" style="width:120px;">
</td>
<td><input type="text" name="folder_name" value="<?= sw_h($mod['folder_name']) ?>" style="width:120px;"></td>
<td>
<select name="mod_type" style="width:120px;">
<option value="client" <?= $mod['mod_type'] === 'client' ? 'selected' : '' ?>>Client</option>
<option value="server" <?= $mod['mod_type'] === 'server' ? 'selected' : '' ?>>Server</option>
</select>
<button type="submit" class="button small">Save</button>
</form>
</td>
<td>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="toggle_mod">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small" style="<?= $mod['enabled'] ? 'background:#5cb85c;color:#fff;' : '' ?>"><?= $mod['enabled'] ? 'On' : 'Off' ?></button>
</form>
</td>
<td>
<?php
$s = $mod['install_status'];
if ($s === 'installed') {
echo '<span class="sw-status-ok">Installed</span>';
} elseif ($s === 'queued') {
echo '<span class="sw-status-queued">Queued</span>';
} elseif ($s === 'failed') {
echo '<span class="sw-status-failed">Failed</span>';
} elseif ($s === 'updating') {
echo '<span class="sw-status-progress">Updating</span>';
} else {
echo '<span class="sw-muted">Not installed</span>';
}
?>
</td>
<td><?= !empty($mod['last_updated_at']) ? sw_h($mod['last_updated_at']) : '-' ?></td>
<td title="<?= sw_h($mod['last_error'] ?? '') ?>"><?= $shortError !== '' ? sw_h($shortError) : '-' ?></td>
<td style="white-space:nowrap;">
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="move_up">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small" <?= $idx === 0 ? 'disabled' : '' ?>>&#9650;</button>
</form>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="move_down">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small" <?= $idx === (count($mods) - 1) ? 'disabled' : '' ?>>&#9660;</button>
</form>
</td>
<td>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="delete_mod">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small danger" onclick="return confirm('Remove this mod from the list?');" style="background:#d9534f;color:#fff;">Remove</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php
}

View file

@ -50,11 +50,16 @@ Start with:
- [docs/architecture/REPOSITORY_OVERVIEW.md](docs/architecture/REPOSITORY_OVERVIEW.md)
- [docs/architecture/PANEL_AGENT_FLOW.md](docs/architecture/PANEL_AGENT_FLOW.md)
- [docs/architecture/API_REFERENCE.md](docs/architecture/API_REFERENCE.md)
- [docs/architecture/PANEL_AGENT_COMMANDS.md](docs/architecture/PANEL_AGENT_COMMANDS.md)
- [docs/architecture/MODULE_DEPENDENCIES.md](docs/architecture/MODULE_DEPENDENCIES.md)
- [docs/architecture/LIBRARY_REFERENCE.md](docs/architecture/LIBRARY_REFERENCE.md)
- [docs/modules/MODULE_INDEX.md](docs/modules/MODULE_INDEX.md)
- [docs/features/STATUS_SYSTEM.md](docs/features/STATUS_SYSTEM.md)
- [docs/features/XML_SYSTEM.md](docs/features/XML_SYSTEM.md)
- [docs/features/WORKSHOP_SYSTEM.md](docs/features/WORKSHOP_SYSTEM.md)
- [docs/features/SCHEDULER_SYSTEM.md](docs/features/SCHEDULER_SYSTEM.md)
- [docs/features/SCHEDULER_ACTIONS.md](docs/features/SCHEDULER_ACTIONS.md)
- [docs/features/USER_API.md](docs/features/USER_API.md)
Additional architecture and decision records:

View file

@ -79,6 +79,18 @@ Relevant function:
The agent reads screen logs and may also copy a local log file into the game home. Logs should be treated as runtime output, not as a state store.
## Workshop / Server Content
The primary Workshop workflow is owned by the Panel `addonsmanager`, not the legacy `steam_workshop` RPC. For Linux servers the Panel:
1. writes `gsp_server_content/workshop_manifest.json` under the server home
2. stages `generic_steam_workshop_linux.sh` under `gsp_server_content/scripts/workshop/`
3. invokes the staged script through the authenticated `exec` RPC
The staged script uses Python and SteamCMD, validates numeric Workshop IDs, keeps writes under the server home, logs to `gsp_server_content/workshop_install.log`, and supports DayZ/Arma-style `@mod` folders plus `.bikey` copying.
The older `steam_workshop` XML-RPC method remains for legacy compatibility only and should not be treated as the primary customer workflow.
## Scheduler
Linux scheduler functions live in `ogp_agent.pl`:

View file

@ -107,6 +107,18 @@ Relevant function:
Windows/Cygwin logs come from screen logs and/or local copies. Log retrieval should remain compatible with the Panel's AJAX log view.
## Workshop / Server Content
The primary Workshop workflow is owned by the Panel `addonsmanager`, not the legacy `steam_workshop` RPC. For Windows/Cygwin servers the Panel:
1. writes `gsp_server_content/workshop_manifest.json` under the server home
2. stages `generic_steam_workshop_windows_cygwin.sh` under `gsp_server_content/scripts/workshop/`
3. invokes the staged script through the authenticated `exec` RPC
The staged script uses Python and SteamCMD, validates numeric Workshop IDs, keeps writes under the server home, logs to `gsp_server_content/workshop_install_windows.log`, and supports DayZ/Arma-style `@mod` folders plus `.bikey` copying.
The older `steam_workshop` XML-RPC method remains for legacy compatibility only and should not be treated as the primary customer workflow.
## Scheduler
Relevant functions:

View file

@ -1,166 +1,267 @@
# API Reference
## Overview
## Scope
Panel-Agent communication uses XML-RPC over HTTP.
This is the master reference for:
Main transport wrapper:
- Panel -> agent XML-RPC
- external Panel API routes
- public status API
- Website payment/webhook endpoints
- `Panel/includes/lib_remote.php`
Detailed command catalog:
Main agent implementation files:
- `docs/architecture/PANEL_AGENT_COMMANDS.md`
- `Agent_Linux/ogp_agent.pl`
- `Agent-Windows/ogp_agent.pl`
Scheduler action catalog:
The Panel encrypts arguments before sending them to the agent. The agent decodes them, performs the requested action locally, then returns a status or payload.
- `docs/features/SCHEDULER_ACTIONS.md`
## General Call Pattern
## Architecture Overview
```text
Panel module
-> OGPRemoteLibrary method in lib_remote.php
-> XML-RPC request to agent /RPC2
-> agent subroutine in ogp_agent.pl
-> local screen/process/file/network action
-> return code or structured payload
home.php module request
-> module PHP page
-> shared include libraries
-> database layer
-> optional agent RPC through lib_remote.php
external automation
-> Panel/ogp_api.php
-> api_* handler
-> same module/library logic
-> optional agent RPC
public node status
-> Panel/status_api.php
-> direct remote host probe + optional agent stats
website checkout/webhook
-> Website/api/* or Website/webhook.php
-> billing runtime
-> DB and payment gateway APIs
```
## Core RPC Methods
## Panel -> Agent XML-RPC
| RPC / Wrapper | Purpose | Common Callers | Notes |
|---|---|---|---|
| `status_chk` | Quick agent reachability check | Panel health checks | Returns a lightweight online/offline/encryption state. |
| `rfile_exists` | Test if a remote file exists | File/content helpers | Used for safe file checks before read/write actions. |
| `get_log` | Fetch screen or console log text | `gamemanager` | Returns log content plus a status code. |
| `remote_stop_server` / `stop_server` | Stop a game server | `gamemanager`, scheduler actions | Takes control protocol and home path data. |
| `remote_send_rcon_command` / `send_rcon_command` | Send RCON/console command | `gamemanager`, `rcon`, scheduler warnings | Useful for warning messages and admin commands. |
| `remote_readfile` / `readfile` | Read a remote file | File editor, config tools | Must remain path-safe. |
| `remote_writefile` / `writefile` | Write a remote file | File editor, config tools | Must remain path-safe. |
| `universal_start` | Start a game server | `gamemanager` | Launches a managed `screen` session. |
| `is_screen_running` | Test whether a managed screen session exists | `gamemanager`, monitor pages | Legacy/simple screen check, not full readiness. |
| `remote_server_status` / `server_status` | Structured server status | `gamemanager` | The preferred source for online/start/stop state. |
| `remote_restart_server` / `restart_server` | Restart a server | `gamemanager` | Intended to be stop, wait, start. |
| `remote_query` | Query game metadata | `gamemanager` | Optional metadata only. |
| `exec` | Execute a constrained agent-side command | `gamemanager` fallback status checks, admin tooling | The Game Monitor currently uses this only as a fallback to test whether the configured game port is listening with `ss`/`netstat`. Customer input must not be passed through this path. |
| `steam_cmd` / `steam` / `automatic_steam_update` | SteamCMD-based update flows | Update actions, Workshop/install tools | Used by Steam-based server maintenance. |
| `start_file_download` | Download external content | Update/content tools | Returns a PID or progress handle. |
| `uncompress_file` | Extract archives | Content installers | Used for zip/tar package installs. |
| `remote_dirlist` / `remote_dirlistfm` | Read remote directory listings | File manager | Used for browse views. |
| `cpu_count`, `renice_process`, `force_cpu` | System/process control helpers | Update/start flows | Used during server launch optimization. |
| `clone_home` / `remove_home` | Duplicate or delete a server home | Provisioning/admin tools | Used during provisioning and teardown. |
| `secure_path`, `get_chattr` | Path safety and attribute helpers | File and security tools | Helps enforce control-path rules. |
| `ftp_mgr` | FTP management helper | FTP module | Used to manage server FTP state. |
| `compress_files` | Archive files | Backup/content tools | Candidate building block for backups. |
| `start_fastdl` / `stop_fastdl` / `restart_fastdl` / `fastdl_status` | FastDL service management | `fast_download` | Source/GoldSrc web distribution support. |
| `scheduler_*` methods | Task list and task CRUD | `cron` | Agent-owned scheduler implementation. |
| `agent_restart` | Restart the agent itself | Admin maintenance | Node maintenance action. |
| `component_update` | Queue a Git-based agent code update | Update module | Admin-only. Uses encrypted XML-RPC, validates repo/branch/source/destination, launches a detached updater, preserves agent config/runtime/server data, and returns queued status plus an agent log path. |
| `shell_action` | Run a shell action in a controlled way | Advanced agent operations | Should remain tightly permissioned. |
| `send_steam_guard_code` | Submit Steam Guard code | Steam authenticated installs | Used for authenticated SteamCMD workflows. |
| `steam_workshop` / `get_workshop_mods_info` | Workshop-related helper calls | Workshop/content flows | Active in current module work, but still being consolidated. |
Transport:
## Status And Return Patterns
- XML-RPC over HTTP
- endpoint path `/RPC2`
- wrapper `Panel/includes/lib_remote.php`
- server implementations in `Agent_Linux/ogp_agent.pl` and `Agent-Windows/ogp_agent.pl`
Return values differ by method, but common patterns are:
Primary categories:
- `1` or positive values for success
- `0` for offline/unavailable
- `-1` or another negative value for error
- structured arrays/hashes for richer status methods
| Category | Examples |
|---|---|
| lifecycle | `server_status`, `universal_start`, `stop_server`, `restart_server` |
| files | `readfile`, `writefile`, `dirlist`, `get_file_part` |
| updates | `steam_cmd`, `component_update`, `stop_update` |
| system | `exec`, `sudo_exec`, `rebootnow`, `what_os`, `discover_ips`, `mon_stats` |
| scheduler | `scheduler_add_task`, `scheduler_edit_task`, `scheduler_del_task`, `scheduler_list_tasks` |
| content | `steam_workshop`, `get_workshop_mods_info` |
`remote_server_status` is the important structured response. It should return the agent as source of truth for:
See the full command table in:
- `docs/architecture/PANEL_AGENT_COMMANDS.md`
## Panel Wrapper Methods
Most Panel modules do not build XML-RPC directly. They call `OGPRemoteLibrary`.
| Wrapper Method | Agent Command | Common Modules |
|---|---|---|
| `status_chk()` | `quick_chk` | `cron`, `server`, `status`, `dashboard`, update pages |
| `remote_server_status()` | `server_status` | `gamemanager` |
| `universal_start()` | `universal_start` | `gamemanager`, API start |
| `remote_stop_server()` | `stop_server` | `gamemanager`, billing, API stop |
| `remote_restart_server()` | `restart_server` | `gamemanager`, API restart |
| `remote_send_rcon_command()` | `send_rcon_command` | `gamemanager`, `rcon`, util helpers |
| `remote_readfile()` / `remote_writefile()` | `readfile` / `writefile` | `litefm`, `editconfigfiles`, `addonsmanager`, `mysql`, `server` |
| `remote_query()` | `remote_query` | `gamemanager`, dashboards |
| `component_update()` | `component_update` | update/admin pages |
| `scheduler_*()` | `scheduler_*` | `cron` |
| `steam_workshop()` | `steam_workshop` | legacy workshop path |
## External Panel API
Primary endpoint:
- `Panel/ogp_api.php`
Auth model:
- token-based
- token issued by `token/create`
- requests can be GET, POST, or JSON body
- host allowlist can be enforced
Top-level API handlers implemented in `ogp_api.php`:
| Handler Function | Route Prefix | Purpose |
|---|---|---|
| `api_token()` | `token/*` | create/test tokens |
| `api_server()` | `server/*` | remote agent CRUD/status |
| `api_user_games()` | `user_games/*` | home provisioning and listing |
| `api_user_admin()` | `user_admin/*` | user CRUD and assignments |
| `api_gamemanager_admin()` | `gamemanager_admin/*` | admin game manager actions |
| `api_gamemanager()` | `gamemanager/*` | start/stop/restart/update/RCON |
| `api_litefm()` | `litefm/*` | file listing/read/write/delete |
| `api_addonsmanager()` | `addonsmanager/*` | content template listing/install |
| `api_server_content()` | `server_content/*` | scheduled content actions |
| `api_steam_workshop()` | `steam_workshop/*` | legacy workshop installs |
| `api_setting()` | `setting/*` | setting read helper |
Detailed route list:
| Route | Purpose |
|---|---|
| `token/create`, `token/test` | auth bootstrap |
| `server/list`, `server/status`, `server/restart`, `server/create`, `server/remove`, `server/add_ip`, `server/remove_ip`, `server/list_ips`, `server/edit_ip` | remote host management |
| `user_games/list_games`, `user_games/list_servers`, `user_games/create`, `user_games/clone`, `user_games/set_expiration` | server-home management |
| `user_admin/list`, `user_admin/get`, `user_admin/create`, `user_admin/remove`, `user_admin/set_expiration`, `user_admin/list_assigned`, `user_admin/assign`, `user_admin/remove_assign` | user management |
| `gamemanager/start`, `gamemanager/stop`, `gamemanager/restart`, `gamemanager/rcon`, `gamemanager/update` | lifecycle automation |
| `gamemanager_admin/reorder` | admin ordering helper |
| `litefm/list`, `litefm/get`, `litefm/save`, `litefm/remove` | remote file operations |
| `addonsmanager/list`, `addonsmanager/install` | add-on templates |
| `steam_workshop/install` | legacy Workshop install |
| `server_content/run_scheduled_action` | typed content action trigger |
| `setting/get` | panel setting read |
See:
- `docs/features/USER_API.md`
## Public Status API
Endpoint:
- `Panel/status_api.php`
Purpose:
- public or semi-public read-only summary of remote nodes
Auth:
- shared query token
Behavior:
- probes remote agents with TCP
- optionally calls agent stats method names if available
- caches JSON locally for 30 seconds
Return shape:
- `generated_at`
- `nodes[]`
- `name`
- `host`
- `agent_port`
- `online`
- `cpu_percent`
- `mem_percent`
- `disk_percent`
## Website API And Webhooks
| Endpoint | Purpose | Auth / Verification |
|---|---|---|
| `Website/api/create_order.php` | create PayPal order | storefront checkout/session context |
| `Website/api/capture_order.php` | capture PayPal order | storefront checkout/session context |
| `Website/api/log_error.php` | client-side error logging | open endpoint, writes log |
| `Website/webhook.php` | verified PayPal webhook processing | OAuth + webhook signature verification |
| `Website/paypal/webhook.php` | compatibility wrapper to active webhook runtime | same |
## Internal Module Endpoints
These are not public APIs in the same sense as `ogp_api.php`, but they matter architecturally.
| Endpoint | Module | Purpose |
|---|---|---|
| `Panel/modules/gamemanager/get_server_log.php` | `gamemanager` | AJAX log refresh |
| `Panel/modules/dashboard/updateWidgets.php` | `dashboard` | dashboard async refresh |
| `Panel/modules/tickets/notificationCount.php` | `tickets` | unread count |
| `Panel/modules/cron/events.php` | `cron` | scheduler log refresh |
| `Panel/modules/litefm/get_file.php` | `litefm` | chunked file downloads |
## Workshop Manifest Contract
Current preferred Workshop/content flow:
1. Panel validates Workshop IDs
2. Panel writes manifest JSON to server home
3. Panel stages bundled helper script to agent host via `writefile`
4. Panel runs helper with `exec`
5. Agent logs/install output is returned to Panel
Common manifest fields:
- `manifest_version`
- `home_id`
- `home_cfg_id`
- `content_type`
- `action`
- `items`
- `options`
For Workshop-specific manifests, the broader fields documented in `WORKSHOP_SYSTEM.md` also apply, including app IDs, install strategy, and target paths.
## Status Contract
`server_status` is the preferred runtime contract.
Expected fields:
- `status`
- `ready`
- `process_running`
- `session_running`
- `game_port_listening`
- `query_port_listening`
- `rcon_port_listening`
- `pid`
- `session_name`
- `ip`
- `port`
- `query_port`
- `rcon_port`
- `last_error`
- `query_info`
State meanings:
- `OFFLINE`
- `STARTING`
- `ONLINE`
- `STARTING`
- `STOPPING`
- `OFFLINE`
- `UNRESPONSIVE`
- `UNKNOWN`
The Panel Game Monitor has a defensive fallback path while the structured status system is still being hardened:
Rules:
```text
remote_server_status if available
-> is_screen_running fallback
-> agent-side port listening check through exec
-> LGSL/GameQ only for optional player/map/query metadata
```
- query failure alone must not imply offline
- agent process/session or game-port evidence is sufficient for online
- `UNKNOWN` is reserved for unreachable or inconclusive agent state
A confirmed managed screen/session, process flag, or listening game port is enough for the Panel to display `ONLINE`. Query failure alone must not produce `OFFLINE` or `UNKNOWN`.
## Scheduler-As-API Contract
Current compatibility behavior:
Agent cron jobs often execute URLs like:
- New agents should expose `server_status` / `remote_server_status`.
- Older agents may only expose `is_screen_running` and generic `exec`.
- The Panel therefore falls back to `is_screen_running` and an agent-side port check.
- Agents report `ONLINE` when the configured game port is listening even if the managed screen/session cannot be found, with `last_error` carrying the warning.
- LGSL/GameQ/Steam query data is optional metadata only.
- `ogp_api.php?gamemanager/start`
- `ogp_api.php?gamemanager/stop`
- `ogp_api.php?gamemanager/restart`
- `ogp_api.php?gamemanager/update&type=steam`
- `ogp_api.php?server_content/run_scheduled_action`
## Sequence Diagrams
This means `ogp_api.php` is part of the scheduler runtime and must stay backward compatible with those generated URLs.
### Start
## Search Coverage Used For This Document
```text
Panel
-> universal_start
Agent
-> create screen session
-> launch server command
Panel
-> remote_server_status polling
Agent
-> session/process + port checks
Panel
-> render STARTING / ONLINE / UNRESPONSIVE
```
### Stop
```text
Panel
-> remote_stop_server
Agent
-> graceful stop command if available
-> wait for session/process exit
-> escalate to kill if needed
Panel
-> remote_server_status polling
Agent
-> confirm OFFLINE or UNRESPONSIVE
```
### Logs
```text
Panel log page
-> get_log
Agent
-> read screen log or console log
Panel
-> refresh dedicated preformatted log panel via AJAX
```
### Remote Agent Component Update
```text
Panel update page
-> component_update(payload)
Agent
-> validate repo, branch, source folder, destination
-> write detached updater script
-> return queued + log path
Updater script
-> clone/fetch repository into staging
-> copy Agent_Linux or Agent-Windows folder only
-> preserve Cfg, ServerFiles, Schedule, logs, steamcmd, startups, pid files
-> validate ogp_agent.pl
-> restart agent through systemd or screen fallback
```
## Error Handling Notes
- Do not treat query failure as a start failure by itself.
- Do not treat marker-file presence as the source of truth.
- Do not expose raw shell execution to customers through generic RPC routes.
- When a method returns a payload, record the error text in the Panel UI if the operation fails.
- `sed -n '1,240p' Panel/ogp_api.php`
- `rg -n "^function api_" Panel/ogp_api.php`
- `sed -n '1,240p' Panel/status_api.php`
- `find Website/api -maxdepth 1 -type f`
- `sed -n '1,220p' Website/webhook.php`

View file

@ -0,0 +1,159 @@
# Library Reference
## Scope
This file documents the shared Panel libraries and helper entrypoints that multiple modules reuse.
Primary directories:
- `Panel/includes/`
- `Panel/protocol/`
No separate `Panel/libraries/` or `Panel/common/` tree is currently used as the main shared layer.
## Core Include Files
| File | Purpose | Used By |
|---|---|---|
| `Panel/includes/config.inc.php` | Main runtime configuration | global bootstrap, APIs, installers |
| `Panel/includes/database.php` | DB abstraction selector | bootstrap |
| `Panel/includes/database_mysqli.php` | Main database implementation and business-query layer | almost all stateful modules |
| `Panel/includes/functions.php` | general utility helpers, formatting, logging, Discord webhook helper | global |
| `Panel/includes/helpers.php` | session, auth, panel helper functions | global |
| `Panel/includes/html_functions.php` | HTML rendering helpers | module UIs |
| `Panel/includes/form_table_class.php` | form builder helper | admin and config forms |
| `Panel/includes/lang.php` | translation loading | global |
| `Panel/includes/lib_remote.php` | XML-RPC wrapper for agent commands | every agent-aware module |
| `Panel/includes/navig.php` | module/page routing | main panel request flow |
| `Panel/includes/view.php` | page shell and shared JS injection | main panel request flow |
| `Panel/includes/refreshed.php` | refresh helper | cron/events and refresh-style pages |
| `Panel/includes/debug.php` | debug support | limited / diagnostic use |
| `Panel/includes/ip_in_range.php` | CIDR / IP-range helper | API host authorization |
| `Panel/includes/api_functions.php` | `ogp_api.php` argument maps and API-side helper logic | external API, scheduler URLs |
## `lib_remote.php` Wrapper Surface
`Panel/includes/lib_remote.php` is the most important shared library in the repository.
Responsibilities:
- XML-RPC request creation
- encryption of parameters
- RPC request transport
- decoding return payloads
- compatibility fallbacks for older agents
Major consumers:
- `gamemanager`
- `cron`
- `server`
- `user_games`
- `addonsmanager`
- `litefm`
- `ftp`
- `mysql`
- `status`
- `dashboard`
- `util`
See:
- `docs/architecture/PANEL_AGENT_COMMANDS.md`
## Database Layer
`Panel/includes/database_mysqli.php` is more than a raw DB adapter. It acts as a domain service layer.
It owns:
- users and roles
- remote servers
- game homes
- config homes and mods
- API tokens
- widget settings
- many module-specific table helpers
Practical rule:
- before adding direct SQL inside a module, check whether `database_mysqli.php` already exposes the needed operation
## API Helper Layer
`Panel/includes/api_functions.php` supports `Panel/ogp_api.php`.
Responsibilities:
- endpoint argument definitions
- startup command generation
- RCON dispatch helper
- API host authorization checks
This file is also indirectly used by the scheduler because cron jobs call back into `ogp_api.php`.
## XML / Game Config Parsing
Shared files:
- `Panel/modules/config_games/server_config_parser.php`
- `Panel/modules/config_games/schema_server_config.xml`
- `Panel/modules/config_games/xml_tag_descriptions.php`
Responsibilities:
- validate game XML files
- load startup/query/install metadata
- expose game config values to `gamemanager`, `user_games`, `addonsmanager`, `dsi`, and APIs
## Protocol Libraries
| Path | Purpose | Used By |
|---|---|---|
| `Panel/protocol/lgsl/` | LGSL query implementation | `gamemanager`, `dsi`, XML helpers |
| `Panel/protocol/GameQ/` | GameQ query implementation | `gamemanager`, `dsi`, XML helpers |
Important note:
- query libraries provide optional metadata
- they are not the source of truth for runtime status
## Bundled Third-Party Libraries In Modules
These are module-local, not general-purpose shared libs, but they matter for architecture.
| Path | Purpose | Main Module |
|---|---|---|
| `Panel/modules/ftp/` bundled net2ftp code | browser-based FTP UI | `ftp` |
| `Panel/modules/TS3Admin/` | TeamSpeak admin webapp | `TS3Admin` |
| `Panel/modules/teamspeak3/ts3admin.class.php` and related files | TeamSpeak integration | `teamspeak3` |
| `Panel/modules/gamemanager/MinecraftRcon.class.php` | Minecraft-specific RCON client | `gamemanager` |
| `Panel/modules/news/include/library/HTMLPurifier/` | content sanitization | `news` |
## Filesystem And Update Helpers
Notable update-related shared logic currently lives in:
- `Panel/modules/administration/panel_update.php`
- `Panel/modules/update/updating.php`
- `Panel/modules/update/patch_manager.php`
- `Panel/modules/update/post_update.php`
These are not in `includes/`, but they function as shared update infrastructure used by the admin-facing update UI.
## Consumers By Concern
| Concern | Primary Shared Files |
|---|---|
| Agent RPC | `includes/lib_remote.php` |
| Database | `includes/database_mysqli.php` |
| API | `includes/api_functions.php`, `ogp_api.php` |
| Routing / page shell | `includes/navig.php`, `includes/view.php` |
| XML config | `modules/config_games/server_config_parser.php` |
| Query metadata | `protocol/lgsl`, `protocol/GameQ` |
| Auth/session helpers | `includes/helpers.php`, `includes/functions.php` |
## Search Coverage Used For This Document
- `find Panel/includes -maxdepth 1 -type f`
- `rg -n "require_once\\('includes/lib_remote.php'|require_once\\(\"modules/config_games/server_config_parser.php\"|require_once\\('protocol/" Panel/modules`

View file

@ -0,0 +1,148 @@
# Module Dependencies
## Scope
This file maps how Panel modules depend on:
- agents
- shared libraries
- other modules
- database tables
- AJAX endpoints
- external services
It answers the practical question: what can call what?
## Core Dependency Layers
```text
User / admin UI
-> home.php module routing
-> module page PHP files
-> shared include libraries
-> database layer
-> agent RPC wrapper (lib_remote.php)
-> Linux / Windows agents
AJAX / external API
-> module endpoint PHP files or ogp_api.php / status_api.php
-> same shared libraries and DB layer
-> agent RPC where needed
```
## Shared Internal Hubs
| Hub | Called By | Purpose |
|---|---|---|
| `Panel/includes/lib_remote.php` | `gamemanager`, `cron`, `server`, `user_games`, `litefm`, `ftp`, `mysql`, `addonsmanager`, `status`, `dashboard`, `util`, `fast_download`, `billing`, `editconfigfiles` | Agent XML-RPC transport |
| `Panel/modules/config_games/server_config_parser.php` | `gamemanager`, `user_games`, `addonsmanager`, `cron`, `dsi`, `config_games`, API | Game XML loading and interpretation |
| `Panel/includes/database_mysqli.php` | effectively all stateful modules | Database abstraction and business queries |
| `Panel/includes/functions.php` and `helpers.php` | global | auth, logging, formatting, small helpers |
| `Panel/includes/view.php` and `navig.php` | routing / page shell | module dispatch and page rendering |
## Module Dependency Table
| Module | Calls Agent | Calls Other Modules / Files | Shared Libraries | Major DB / State | AJAX / API Endpoints | External Integrations |
|---|---|---|---|---|---|---|
| `administration` | Indirectly, for update and logger views | `panel_update.php`, backup/log helpers | `functions.php`, DB layer, `lib_remote.php` | settings, remote server metadata | `watch_logger.php` | Git / Forgejo, filesystem |
| `addonsmanager` | Yes | `workshop_action.php`, `server_content_actions.php`, `server_content_helpers.php`, `config_games` | `lib_remote.php`, XML parser | `addons`, `server_content_manifest`, `server_content_workshop` | `workshop_action.php` | SteamCMD, Workshop, archive installers |
| `backup-restore` | Limited / indirect | local helper function file | DB layer, filesystem helpers | backup metadata if present | module pages | zip/tar filesystem work |
| `billing` | Yes for server provisioning/expiry actions | embeds storefront runtime; shares logic with `Website/` | DB layer, `lib_remote.php`, payment helpers | orders, invoices, coupons, server provisioning state | webhook and checkout entrypoints under module and website | PayPal, Stripe/manual gateways, email |
| `circular` | No | own helper file | DB layer | announcement/content records | `show_circular.php` | none |
| `config_games` | No direct agent use in main UI | `server_config_parser.php`, `cli-params.php`, `set_mods.php`, `set_params.php` | XML parser, DB layer | `config_homes`, `config_mods` | XML editor-like pages | GameQ, LGSL references |
| `cron` | Yes | `shared_cron_functions.php`, uses `ogp_api.php` URLs as scheduled payloads | `lib_remote.php`, XML parser, DB layer | Panel-side scheduler intent; agent-owned cron entries | `events.php`, `thetime.php`, `user_cron.php` | agent scheduler, `wget`, panel API |
| `dashboard` | Yes | `query_ref.php`, `updateWidgets.php` | `lib_remote.php`, DB layer | widget prefs, server overview | `updateWidgets.php` | query protocols |
| `dsi` | Yes | shared DSI includes, `gamemanager/home_handling_functions.php`, XML parser | `lib_remote.php`, XML parser, GameQ, LGSL | server and game metadata | `image.php`, list/admin pages | GameQ, LGSL, GeoIP |
| `editconfigfiles` | Yes | its own helpers/config lists | `lib_remote.php`, DB layer | file edit permissions by home | `modify.php` | remote file editing |
| `faq` | No | RSS/parser helper | DB/content layer | FAQ/news content | `faq.js` | RSS parsing |
| `fast_download` | Yes | admin/user pages, helper file | `lib_remote.php`, DB layer | fastdl config aliases | module pages | web server / FastDL service |
| `ftp` | Yes | bundled net2ftp app files, admin wrapper | `lib_remote.php`, DB layer | remote server FTP config | admin and embedded FTP UI | Pure-FTPd / FTP service, net2ftp |
| `gamemanager` | Yes | `home_handling_functions.php`, start/stop/restart/log pages | `lib_remote.php`, XML parser, helpers | server homes, last params, runtime status | `get_server_log.php`, `log.php`, `rcon.php` | screen, RCON, query protocols, SteamCMD |
| `lgsl_with_img_mod` | No agent by default | own LGSL pages | LGSL | server query presentation | `feed.php`, `image.php` | LGSL |
| `litefm` | Yes | file manager pages and helpers | `lib_remote.php`, session helpers | session download state | `fm_dir.php`, `fm_read.php`, `fm_write.php`, `get_file.php` | remote file management |
| `lostpwd` | No | own page | DB layer, mail helpers | users | form endpoints | email |
| `modulemanager` | No agent | `module_handling.php`, update modules | filesystem, DB layer | installed module metadata | add/delete/update pages | filesystem |
| `mysql` | Yes | own DB server management pages | `lib_remote.php`, DB layer | mysql server records, dumps | `get_dump.php` | MySQL server administration |
| `news` | No | admin upload/config pages | DB/content libs | news posts | upload/admin pages | file upload |
| `rcon` | Yes | standalone RCON page | `lib_remote.php`, XML parser | server home lookup | module page | RCON |
| `register` | No agent | registration helper pages | DB layer | users | `register-exec.php` | captcha/email if configured |
| `server` | Yes | add/edit/restart/reboot/firewall/view_log pages | `lib_remote.php`, DB layer | `remote_servers`, IP allocation | `mon_stats.php`, `view_log.php` | agent lifecycle, firewall, reboot |
| `settings` | No agent by default | `api_hosts.php`, theme/settings pages | DB layer, API helpers | `settings`, authorized-host files | settings pages | Discord webhook config, API host files |
| `status` | Yes | `modules/status/include/*` | `lib_remote.php`, config helpers | remote server list | embedded include pages | CPU/RAM/disk/uptime monitoring |
| `subusers` | No direct agent | own CRUD pages | DB layer | users, permissions | form endpoints | none |
| `support` | No agent | own page | DB layer, `functions.php` | support requests | support form page | Discord webhook |
| `teamspeak3` | No generic agent RPC | own TS3 admin lib set | TS3-specific classes, DB layer | TS3 homes/config | TS3 pages | TeamSpeak query/admin |
| `tickets` | No agent by default | attachment, rating, settings, count, submit, view pages | DB layer | tickets, attachments, ratings | `notificationCount.php`, `submitTicket.php`, `downloadAttachment.php` | email/notifications if configured |
| `TS3Admin` | No generic agent RPC | bundled TS3 admin web app | TS3 admin classes | TS3 runtime/admin data | JS-driven views | TeamSpeak admin interface |
| `tshock` | No generic agent RPC | shared TShock helpers | DB layer | Terraria/TShock tokens/config | create token and management pages | TShock REST/API |
| `update` | Indirectly through `administration/panel_update.php` and agent `component_update` | `updating.php`, patch manager | filesystem, DB/settings, `lib_remote.php` | update settings, backups, last update files | module pages | Git / Forgejo |
| `user_admin` | No direct agent | user/group CRUD pages | DB layer | users, groups, assignments | form endpoints | none |
| `user_games` | Yes | provisioning, clone, migrate, custom fields, browser | `lib_remote.php`, XML parser, DB layer | `server_homes`, `user_homes`, IP allocations, configs | `get_size.php`, browser pages | filesystem, provisioning, rsync |
| `util` | Yes | addadmin helpers, SteamID tools, network tools | `lib_remote.php`, DB layer | server lookup | helper pages | RCON, Steam ID conversion |
## Module-To-Module Relationships
| Source Module | Target Module / File | Why |
|---|---|---|
| `gamemanager` | `config_games/server_config_parser.php` | build startup commands, query definitions, control protocol metadata |
| `gamemanager` | `addonsmanager/monitor_buttons.php` and content helpers indirectly | content/update actions shown on monitor pages |
| `cron` | `addonsmanager/server_content_actions.php` via `ogp_api.php?server_content/run_scheduled_action` | scheduled content checks and installs |
| `cron` | `gamemanager` via `ogp_api.php?gamemanager/*` | start, stop, restart, Steam auto update |
| `user_games` | `gamemanager/home_handling_functions.php` | delete / teardown and lifecycle-related helpers |
| `user_games` | `billing/create_servers.php` | provisioning integration |
| `dsi` | `gamemanager/home_handling_functions.php` | shared game-home status logic |
| `update` | `administration/panel_update.php` | actual admin-facing updater implementation |
| `settings` | `api_functions.php` | authorized host list UI for external API |
| `billing` | `Website/` mirrored runtime | storefront and payment logic is shared between panel module and public site |
## AJAX And Async Endpoints
| Endpoint | Owning Module | Purpose |
|---|---|---|
| `Panel/modules/gamemanager/get_server_log.php` | `gamemanager` | live log refresh |
| `Panel/modules/dashboard/updateWidgets.php` | `dashboard` | dashboard widget refresh |
| `Panel/modules/tickets/notificationCount.php` | `tickets` | unread/support badge count |
| `Panel/modules/cron/events.php` | `cron` | scheduler log refresh |
| `Panel/modules/litefm/get_file.php` | `litefm` | streamed download chunks |
| `Panel/status_api.php` | architecture / public status | panel-local public node summary API |
| `Panel/ogp_api.php` | architecture / external API | token-authenticated external automation API |
## Major Database Ownership
This is not a full schema map. It identifies ownership boundaries.
| Table / Group | Main Owners |
|---|---|
| `settings` | `settings`, `update`, general bootstrap |
| `users`, `user_groups`, `user_homes` | `user_admin`, `subusers`, `register`, auth/session flows |
| `remote_servers`, `remote_server_ips` | `server`, provisioning, `status`, `dashboard` |
| `server_homes`, `home_ip_ports`, `game_mods` | `user_games`, `gamemanager`, `cron`, provisioning |
| `config_homes`, `config_mods` | `config_games`, `user_games`, `gamemanager` |
| `api_tokens` | `ogp_api.php`, user session helpers |
| `server_content_manifest`, `server_content_workshop`, `addons` | `addonsmanager` |
| billing/order/invoice/coupon tables | `billing`, `Website/` |
| ticket/support tables | `tickets`, `support` |
| widget / dashboard tables | `dashboard` |
## External Integration Map
| Integration | Primary Modules |
|---|---|
| SteamCMD | `gamemanager`, `addonsmanager`, Workshop flows, agents |
| Steam Workshop | `addonsmanager`, legacy `steam_workshop`, agents |
| GameQ / LGSL | `gamemanager`, `dashboard`, `dsi`, XML config |
| RCON | `gamemanager`, `rcon`, `util`, scheduler-triggered API calls |
| Git / Forgejo | `update`, `administration/panel_update.php`, agent `component_update` |
| FTP / Pure-FTPd / net2ftp | `ftp`, provisioning |
| Discord webhooks | `settings`, `support`, shared `functions.php::discordmsg` |
| PayPal / Stripe / manual payments | `billing`, `Website/api/*`, `Website/webhook.php` |
| TeamSpeak | `teamspeak3`, `TS3Admin` |
| TShock | `tshock` |
## Search Coverage Used For This Document
Key searches:
- `rg -n "new OGPRemoteLibrary|->remote_|->scheduler_|->component_update|->steam_workshop" Panel/modules Panel/includes`
- `rg -n "require|require_once|include|include_once" Panel/modules -g '*.php'`
- `find Panel/modules -maxdepth 2 -type f \\( -name '*.php' -o -name '*.js' \\)`

View file

@ -0,0 +1,208 @@
# Panel-Agent Commands
## Scope
This file is the command catalog for the XML-RPC surface between the Panel and the agents.
Primary files:
- `Panel/includes/lib_remote.php`
- `Agent_Linux/ogp_agent.pl`
- `Agent-Windows/ogp_agent.pl`
Transport:
- Panel -> agent uses XML-RPC over HTTP to `/RPC2`
- Arguments are encrypted by `OGPRemoteLibrary`
- Agents validate the encryption marker before executing commands
## Command Dispatch
Linux dispatch table:
- `Agent_Linux/ogp_agent.pl`
Windows dispatch table:
- `Agent-Windows/ogp_agent.pl`
Important parity note:
- Linux exposes `renice_process`
- Linux exposes `lock_additional_files`
- Windows does not expose those two commands in its current dispatcher
## Return Conventions
Common patterns:
- `1`: success
- `0`: false, offline, or not running depending on command
- negative values: error or unreachable state
- arrays / hashes: structured payloads for newer commands
- base64-encoded text payloads are common for file, shell, and stats responses
## Lifecycle And Status Commands
| Agent Command | Purpose | Key Arguments | Returns | Windows | Linux | Panel Callers | Security Notes |
|---|---|---|---|---|---|---|---|
| `quick_chk` | Lightweight reachability and encryption check | none | `1`, `0`, or error | Yes | Yes | `lib_remote.php::status_chk`, most modules before remote work | Safe probe; no server-side mutation |
| `is_screen_running` | Legacy managed-session check | `screen_type`, `home_id` | `1`, `0`, `-1` | Yes | Yes | `gamemanager`, status fallback, legacy monitor code | Session existence only; not a full readiness check |
| `server_status` | Structured server state check | `home_id`, `server_ip`, `server_port`, `query_port`, `rcon_port`, `startup_timeout`, `state_hint` | hash with state flags | Yes | Yes | `gamemanager/home_handling_functions.php`, `server_monitor.php` | Preferred source of truth for runtime state |
| `universal_start` | Start a game server | `home_id`, `game_home`, `game_binary`, `run_dir`, `startup_cmd`, `server_port`, `server_ip`, `cpu`, `nice`, `preStart`, `envVars`, `game_key`, `console_log` | `1`, `-1`, `-2` | Yes | Yes | `gamemanager/start_server.php`, restart flows, API start | Starts managed `screen` session and game command |
| `stop_server` | Stop a running server | `home_id`, `server_ip`, `server_port`, `control_protocol`, `control_password`, `control_type`, `game_home` | `1`, `0`, negative error | Yes | Yes | `gamemanager/stop_server.php`, restart flows, API stop, billing expiry flows | Can issue control-protocol commands and escalation kills |
| `restart_server` | Restart a running server | start + stop argument set | `1`, `0`, `-1`, `-2` | Yes | Yes | `gamemanager/restart_server.php`, API restart, scheduler | Depends on both stop and start paths working |
| `send_rcon_command` | Send RCON / console command | `home_id`, `server_ip`, `server_port`, `control_protocol`, `control_password`, `control_type`, `command`, `return` | `1`, `-10`, error | Yes | Yes | `gamemanager/rcon.php`, `rcon`, util helpers, API rcon | Admin/customer command surface; must not accept untrusted shell text |
| `remote_query` | Query game metadata | `protocol`, `game_type`, `ip`, `c_port`, `q_port`, `s_port` | base64 payload or null/error | Yes | Yes | `gamemanager`, dashboard, DSI | Metadata only; not source of truth for online/offline |
| `renice_process` | Adjust process priority | process / nice inputs | success / error | No | Yes | `lib_remote.php::renice_process`, startup tuning | Linux-only; process control |
| `cpu_count` | Return CPU/core count | none | integer | Yes | Yes | start flows, provisioning, tuning UIs | Read-only system info |
## File And Filesystem Commands
| Agent Command | Purpose | Key Arguments | Returns | Windows | Linux | Panel Callers | Security Notes |
|---|---|---|---|---|---|---|---|
| `rfile_exists` | Check remote file existence | `path` | `1` / `0` | Yes | Yes | file tools, content helpers | Must remain path-safe |
| `readfile` | Read remote file | `path` | status + base64 data | Yes | Yes | `litefm`, `gamemanager`, `editconfigfiles`, `mysql`, `server`, API helpers | Reads customer server files and some agent logs |
| `writefile` | Write remote file | `path`, `content` | `1` / error | Yes | Yes | `litefm`, `editconfigfiles`, `gamemanager`, `addonsmanager`, `mysql`, `fast_download`, `server` | High-risk write path; caller must validate target path |
| `dirlist` | Directory listing | `path` | array / error | Yes | Yes | `gamemanager/mini_start.php`, older browsers | Read-only |
| `dirlistfm` | File-manager-specific directory listing | `path` | array / error | Yes | Yes | `litefm`, `user_games/browser.php` | Read-only |
| `get_file_part` | Stream file in chunks | `path`, `offset` | next offset + chunk | Yes | Yes | `litefm/get_file.php` | Download helper for large files |
| `secure_path` | Agent path validation helper | `action`, `path` | base64 payload / validation result | Yes | Yes | update/content/security flows | Important for safe agent-side path operations |
| `get_chattr` | Read filesystem attributes | `path` | attributes text | Yes | Yes | admin/security tools | Mostly Linux semantics |
| `lock_additional_files` | Lock or unlock extra files | `game_home`, `filesToLockUnlock`, `action` | success / error | No | Yes | `lib_remote.php::lock_additional_home_files` | Linux-only file-attribute helper |
| `clone_home` | Copy a game home | `source_home`, `dest_home`, `owner` | `1`, `0`, `-1` | Yes | Yes | `user_games/clone_home.php`, provisioning | Long-running filesystem mutation |
| `remove_home` | Delete a game home | `game_home_del` | `1`, `0`, `-1` | Yes | Yes | `user_games/del_home.php`, teardown flows | Destructive; must be admin or owner-controlled |
| `compress_files` | Build archive from remote files | `files`, `destination`, `archive_name`, `archive_type` | success / error | Yes | Yes | backup/content/fastdl flows | Archive path must stay inside allowed roots |
| `uncompress_file` | Extract archive | archive inputs | success / error | Yes | Yes | installers, content tools | Extraction path must be constrained |
## Download, Install, And Update Commands
| Agent Command | Purpose | Key Arguments | Returns | Windows | Linux | Panel Callers | Security Notes |
|---|---|---|---|---|---|---|---|
| `steam_cmd` | Run SteamCMD-based install/update | steam-specific arguments | status code | Yes | Yes | `gamemanager/update_server.php`, API update | Can change server binaries and Steam content |
| `fetch_steam_version` | Fetch Steam app version metadata | app arguments | version string / error | Yes | Yes | update pages | Read-only Steam helper |
| `installed_steam_version` | Read installed Steam app version | path/app inputs | version string / error | Yes | Yes | update pages | Read-only |
| `automatic_steam_update` | Auto-update via SteamCMD | update args | status code | Yes | Yes | scheduler/API update path | Mutation of game install |
| `start_file_download` | Begin remote file download | URL and target args | pid / handle / status | Yes | Yes | installers, update/content flows | External network fetch; validate destination |
| `is_file_download_in_progress` | Poll active download | download handle | status | Yes | Yes | update/content flows | Read-only |
| `start_rsync_install` | Begin rsync-based install/copy | rsync args | status | Yes | Yes | migration/provisioning | Long-running copy action |
| `rsync_progress` | Poll rsync copy | rsync handle | progress | Yes | Yes | migration/provisioning | Read-only |
| `master_server_update` | Legacy master server update helper | update args | status | Yes | Yes | legacy update flows | Administrative |
| `stop_update` | Abort active update | `home_id` | `1` / error | Yes | Yes | update UI, `lib_remote.php::stop_update` | Mutates running install/update |
| `component_update` | Update agent code from Git staging | encrypted JSON payload | structured hash | Yes | Yes | `administration/panel_update.php` | Admin-only; must preserve config and hosted server data |
| `agent_restart` | Restart the agent service/process | marker arg | `1` / error | Yes | Yes | `server/restart.php` | Admin-only node control |
## Workshop And Server Content Commands
| Agent Command | Purpose | Key Arguments | Returns | Windows | Linux | Panel Callers | Security Notes |
|---|---|---|---|---|---|---|---|
| `steam_workshop` | Legacy direct Workshop installer | `home_id`, `mods_full_path`, `workshop_id`, `mods_list`, regex/config args, auth args, download args | `1`, `-1`, `-2`, `-3`, `-4` | Yes | Yes | legacy `steam_workshop` module and compatibility helpers | High-risk legacy surface; keep compatibility but prefer Server Content Manager |
| `get_workshop_mods_info` | Enumerate installed Workshop mods | none / `mods_info` marker | status + mod list | Yes | Yes | Workshop UIs, compatibility views | Read-only |
Current preferred implementation path:
- `addonsmanager` stages a manifest and helper script through `writefile`
- it executes the helper through `exec`
- it uses `steam_workshop` only as legacy compatibility, not as the primary workflow
## Shell And System Commands
| Agent Command | Purpose | Key Arguments | Returns | Windows | Linux | Panel Callers | Security Notes |
|---|---|---|---|---|---|---|---|
| `exec` | Execute a constrained command and return output | `command` | base64 text output | Yes | Yes | status fallback, content helpers, provisioning, misc admin tools | Must never receive raw customer shell input |
| `sudo_exec` | Execute privileged command | `command` | base64 text output | Yes | Yes | advanced admin tooling | Highest-risk command; admin-only |
| `shell_action` | Structured shell action abstraction | `action`, `arguments` | array or text | Yes | Yes | newer advanced operations | Safer than raw shell when used correctly |
| `rebootnow` | Reboot remote host | none | status | Yes | Yes | `server/reboot.php` | Node-level destructive action |
| `what_os` | Return agent OS identifier | none | OS string | Yes | Yes | provisioning, workshop script selection, path handling | Read-only |
| `discover_ips` | Return agent IP list | marker arg | CSV / array | Yes | Yes | server provisioning | Read-only |
| `mon_stats` | Return agent monitoring stats | marker arg | base64 stats text | Yes | Yes | `server/mon_stats.php`, `status` | Read-only system metrics |
## FTP And Fast Download Commands
| Agent Command | Purpose | Key Arguments | Returns | Windows | Linux | Panel Callers | Security Notes |
|---|---|---|---|---|---|---|---|
| `ftp_mgr` | Manage FTP account state | `action`, `login`, `password`, `home_path` | text/status | Yes | Yes | `ftp/ftp_admin.php`, provisioning | Touches service credentials and user state |
| `stop_fastdl` | Stop FastDL service | none | status | Yes | Yes | `fast_download` admin UI | Node service control |
| `restart_fastdl` | Restart FastDL service | none | status | Yes | Yes | `fast_download` admin UI | Node service control |
| `fastdl_status` | Check FastDL status | none | status | Yes | Yes | `fast_download` | Read-only |
| `fastdl_get_aliases` | List FastDL aliases | none | alias list | Yes | Yes | `fast_download` | Read-only |
| `fastdl_add_alias` | Add FastDL alias | alias args | status | Yes | Yes | `fast_download` | Writes service config |
| `fastdl_del_alias` | Delete FastDL alias | alias list | status | Yes | Yes | `fast_download` | Writes service config |
| `fastdl_get_info` | Return FastDL config info | none | info payload | Yes | Yes | `fast_download` | Read-only |
| `fastdl_create_config` | Build FastDL config | bind/config args | status | Yes | Yes | `fast_download` | Writes service config |
## Scheduler Commands
| Agent Command | Purpose | Key Arguments | Returns | Windows | Linux | Panel Callers | Security Notes |
|---|---|---|---|---|---|---|---|
| `scheduler_add_task` | Add cron entry | `job` | status | Yes | Yes | `cron/cron.php`, `cron/user_cron.php` | Agent executes stored commands locally |
| `scheduler_edit_task` | Modify cron entry | `job_id`, `job` | status | Yes | Yes | `cron/cron.php`, token refresh helpers | Same risks as add |
| `scheduler_del_task` | Remove cron entry | `job_id` or comma list | status | Yes | Yes | `cron/cron.php`, cleanup helpers | Destructive |
| `scheduler_list_tasks` | Return cron table | none | map of job IDs to cron lines | Yes | Yes | `cron/shared_cron_functions.php`, `cron/events.php` | Read-only |
Important implementation note:
- the Panel scheduler stores intent in the UI
- the agent owns execution timing and the actual cron entries
- many agent cron commands are `wget` calls back into `Panel/ogp_api.php`
## Internal Agent Scheduler Actions
These are not XML-RPC command names, but they are part of the runtime architecture inside both agents:
- `scheduler_dispatcher`
- `scheduler_server_action`
- `scheduler_log_events`
- `scheduler_read_tasks`
- `scheduler_stop`
The built-in action names handled by the Panel-generated API URLs are:
- `start`
- `stop`
- `restart`
- `steam_auto_update`
- `server_content_check_updates`
- `server_content_check_workshop_updates`
- `server_content_install_updates_if_stopped`
- `server_content_install_updates_next_restart`
- `server_content_install_updates_now`
- `server_content_install_updates_and_restart`
- `server_content_notify_updates_only`
- `server_content_update_all`
- `server_content_validate_files`
- `server_content_backup_before_update`
## Panel Wrapper Map
Primary wrapper file:
- `Panel/includes/lib_remote.php`
Notable wrapper methods:
| Wrapper Method | XML-RPC Command |
|---|---|
| `status_chk()` | `quick_chk` |
| `remote_server_status()` | `server_status` |
| `universal_start()` | `universal_start` |
| `remote_stop_server()` | `stop_server` |
| `remote_restart_server()` | `restart_server` |
| `remote_send_rcon_command()` | `send_rcon_command` |
| `remote_readfile()` | `readfile` |
| `remote_writefile()` | `writefile` |
| `remote_query()` | `remote_query` |
| `scheduler_add_task()` | `scheduler_add_task` |
| `scheduler_edit_task()` | `scheduler_edit_task` |
| `scheduler_del_task()` | `scheduler_del_task` |
| `scheduler_list_tasks()` | `scheduler_list_tasks` |
| `component_update()` | `component_update` |
| `steam_workshop()` | `steam_workshop` |
## Search Coverage Used For This Document
Commands were confirmed from:
- `rg -n "methods =>" Agent_Linux Agent-Windows`
- `rg -n "^sub .*without_decrypt|^sub component_update|^sub remote_query|^sub steam_workshop|^sub scheduler_" Agent_Linux/ogp_agent.pl Agent-Windows/ogp_agent.pl`
- `rg -n "public function .*\\(" Panel/includes/lib_remote.php`

View file

@ -2,7 +2,7 @@
## Status
Accepted, Phase 1 implementation started
Accepted, Phase 1 implementation active
## Decision
@ -36,3 +36,17 @@ Phase 1 implements this decision by routing the user-facing Workshop install flo
- Generic content installs under `{SERVER_ROOT}/workshop/{MOD_FOLDER}` by default.
- DayZ/Arma-style installs default to `@<workshop_id>` folders and copy `.bikey` files into `keys` when present.
- Startup parameter generation remains a later phase.
- Game XML uses the canonical `workshop_support` block. The schema validates this block and no longer requires loose top-level Workshop tags.
- The Panel helpers read `workshop_support` first, then tolerate older direct tags only as compatibility fallbacks.
- Arma 3 Linux and Windows configs declare Workshop app ID `107410` through `workshop_support`.
## Validation
Current validation commands:
```bash
php Panel/modules/addonsmanager/tests/workshop_helpers_test.php
php Panel/modules/config_games/tests/validate_server_configs.php
bash -n Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh
bash -n Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh
```

View file

@ -18,14 +18,19 @@ This file is the first stop for future Codex sessions working in this repository
1. `docs/architecture/REPOSITORY_OVERVIEW.md`
2. `docs/architecture/PANEL_AGENT_FLOW.md`
3. `docs/architecture/API_REFERENCE.md`
4. `docs/modules/MODULE_INDEX.md`
5. `docs/modules/GAMEMANAGER.md`
6. `docs/features/STATUS_SYSTEM.md`
7. `docs/features/XML_SYSTEM.md`
8. `docs/modules/SCHEDULER.md`
9. `docs/modules/SERVER_CONTENT_MANAGER.md`
10. `docs/decisions/`
11. `docs/games/`
4. `docs/architecture/PANEL_AGENT_COMMANDS.md`
5. `docs/architecture/MODULE_DEPENDENCIES.md`
6. `docs/architecture/LIBRARY_REFERENCE.md`
7. `docs/modules/MODULE_INDEX.md`
8. `docs/modules/GAMEMANAGER.md`
9. `docs/features/STATUS_SYSTEM.md`
10. `docs/features/XML_SYSTEM.md`
11. `docs/modules/SCHEDULER.md`
12. `docs/features/SCHEDULER_ACTIONS.md`
13. `docs/modules/SERVER_CONTENT_MANAGER.md`
14. `docs/features/USER_API.md`
15. `docs/decisions/`
16. `docs/games/`
## Important Files By Topic

View file

@ -0,0 +1,103 @@
# Scheduler Actions
## Scope
This file is the command reference for the current scheduler system.
Primary files:
- `Panel/modules/cron/cron.php`
- `Panel/modules/cron/user_cron.php`
- `Panel/modules/cron/shared_cron_functions.php`
- `Agent_Linux/ogp_agent.pl`
- `Agent-Windows/ogp_agent.pl`
## Current Model
The Panel scheduler builds a cron expression and a command string.
In the common case, that command string is a `wget` call back into:
- `Panel/ogp_api.php`
The agent stores the cron entry and executes it locally.
## Action Catalog
| Action Key | Built By | Effective Runtime Target | Modules Affected | Agent Calls Eventually Performed |
|---|---|---|---|---|
| `start` | `cron/shared_cron_functions.php` | `ogp_api.php?gamemanager/start` | `cron`, `gamemanager` | `universal_start` |
| `stop` | same | `ogp_api.php?gamemanager/stop` | `cron`, `gamemanager` | `stop_server` |
| `restart` | same | `ogp_api.php?gamemanager/restart` | `cron`, `gamemanager` | `restart_server` |
| `steam_auto_update` | same | `ogp_api.php?gamemanager/update&type=steam` | `cron`, `gamemanager` | `steam_cmd` / auto-update path |
| `server_content_check_updates` | same | `ogp_api.php?server_content/run_scheduled_action` | `cron`, `addonsmanager` | server-content manifest flow, remote `exec` / helper scripts |
| `server_content_check_workshop_updates` | same | same | `cron`, `addonsmanager` | Workshop/content check flow |
| `server_content_install_updates_if_stopped` | same | same | `cron`, `addonsmanager`, `gamemanager` | conditional install |
| `server_content_install_updates_next_restart` | same | same | `cron`, `addonsmanager`, `gamemanager` | deferred install marker |
| `server_content_install_updates_now` | same | same | `cron`, `addonsmanager` | immediate content install |
| `server_content_install_updates_and_restart` | same | same | `cron`, `addonsmanager`, `gamemanager` | content update plus restart |
| `server_content_notify_updates_only` | same | same | `cron`, `addonsmanager` | check-and-notify |
| `server_content_update_all` | same | same | `cron`, `addonsmanager` | aggregate update flow |
| `server_content_validate_files` | same | same | `cron`, `addonsmanager` | validation flow |
| `server_content_backup_before_update` | same | same | `cron`, `addonsmanager`, backup-related helpers | backup hook before content update |
## Agent Scheduler RPCs
| RPC | Purpose |
|---|---|
| `scheduler_add_task` | add cron line |
| `scheduler_edit_task` | update cron line |
| `scheduler_del_task` | delete cron line |
| `scheduler_list_tasks` | list cron lines |
## Internal Agent Scheduler Subroutines
| Subroutine | Purpose |
|---|---|
| `scheduler_dispatcher` | top-level cron callback |
| `scheduler_server_action` | execute parsed action |
| `scheduler_log_events` | append `scheduler.log` |
| `scheduler_read_tasks` | reload current cron entries |
| `scheduler_stop` | stop and rebuild cron object |
## Runtime Flow
```text
User/admin saves scheduler job in Panel
-> Panel builds cron line
-> Panel sends cron line to agent with scheduler_add_task/edit_task
-> Agent stores job
-> Agent executes cron job later
-> cron job usually calls ogp_api.php
-> ogp_api.php dispatches to gamemanager or server_content action
-> those paths may call the agent again for actual server/content work
```
This means the current scheduler is two-hop:
1. agent cron executes a Panel API URL
2. the Panel API route often calls back to the same or another agent
## Logging
Current observable logs:
- agent-side `scheduler.log`
- panel-side UI through `Panel/modules/cron/events.php`
- module-specific logs from `gamemanager` or `addonsmanager`
## Limitations
| Limitation | Effect |
|---|---|
| string-based cron commands | weaker typing and validation |
| action permissions are implicit | customer-safe vs admin-only is not strongly modeled |
| result storage is agent-log-centric | poor user-facing job history |
| jobs depend on Panel URL/token validity | token rotations require cron rewrite |
| many actions are API callbacks, not local structured tasks | more moving parts and harder debugging |
## Search Coverage Used For This Document
- `sed -n '1,260p' Panel/modules/cron/shared_cron_functions.php`
- `rg -n "scheduler_" Agent_Linux/ogp_agent.pl Agent-Windows/ogp_agent.pl`
- `rg -n "gamemanager/(start|stop|restart)|server_content/run_scheduled_action" Panel/modules/cron`

171
docs/features/USER_API.md Normal file
View file

@ -0,0 +1,171 @@
# User API
## Scope
This file documents the externally reachable API and webhook surfaces discovered in the repository.
Primary files:
- `Panel/ogp_api.php`
- `Panel/status_api.php`
- `Website/api/create_order.php`
- `Website/api/capture_order.php`
- `Website/api/log_error.php`
- `Website/webhook.php`
- `Website/paypal/webhook.php`
## Panel Automation API
Main endpoint:
- `Panel/ogp_api.php`
Transport:
- GET, POST, or JSON request body
- response is usually JSON
- `setting/get` returns plain text
Authentication:
- token-based
- token created through `token/create`
- host allowlist can be enforced with `api_authorized.hosts` and `api_authorized.fwd_hosts`
Important notes:
- the API is not a public anonymous API
- some routes are meaningful for normal users
- many routes are effectively admin-only because they mutate remote servers, create homes, or manage users
### Authentication Endpoints
| Endpoint | Auth | Purpose | Parameters | Returns |
|---|---|---|---|---|
| `ogp_api.php?token/create` | panel username/password | issue API token | `user`, `password` | JSON token payload |
| `ogp_api.php?token/test` | token | verify token | `token` | role/status |
### User-Visible Game Server Actions
| Endpoint | Auth | Purpose | Parameters | Returns |
|---|---|---|---|---|
| `gamemanager/start` | token + home access | start server | `ip`, `port`, optional `mod_key` | JSON status |
| `gamemanager/stop` | token + home access | stop server | `ip`, `port`, optional `mod_key` | JSON status |
| `gamemanager/restart` | token + home access | restart server | `ip`, `port`, optional `mod_key` | JSON status |
| `gamemanager/rcon` | token + home access | send RCON/console command | `ip`, `port`, optional `mod_key`, `command` | JSON command result |
| `gamemanager/update` | token + home access | update server | `ip`, `port`, optional `mod_key`, `type`, optional `manual_url` | JSON status |
| `litefm/list` | token + home access | list files | `ip`, `port`, `relative_path` | JSON listing |
| `litefm/get` | token + home access | read file | `ip`, `port`, `relative_path` | JSON file content |
| `litefm/save` | token + home access | write file | `ip`, `port`, `relative_path`, `contents` | JSON status |
| `litefm/remove` | token + home access | delete file | `ip`, `port`, `relative_path` | JSON status |
| `addonsmanager/list` | token | list add-on templates | `token` | JSON list |
| `addonsmanager/install` | token + home access | install named add-on | `ip`, `port`, `addon_id` | JSON status |
| `steam_workshop/install` | token + home access | legacy Workshop install | `ip`, `port`, optional `mod_key`, `mods_list` | JSON status |
| `server_content/run_scheduled_action` | token + home access | trigger typed server-content action | `home_id`, `action`, optional `options` | JSON status |
### Admin-Oriented API Routes
| Endpoint | Auth | Purpose | Parameters | Returns |
|---|---|---|---|---|
| `server/list` | admin token | list remote agents | `token` | JSON list |
| `server/status` | admin token | status of remote agent | `remote_server_id` | JSON status |
| `server/restart` | admin token | restart agent | `remote_server_id` | JSON status |
| `server/create` | admin token | create remote agent record | agent connection fields | JSON status |
| `server/remove` | admin token | remove remote agent record | `remote_server_id` | JSON status |
| `server/add_ip` | admin token | add IP to agent | `remote_server_id`, `ip` | JSON status |
| `server/remove_ip` | admin token | remove IP from agent | `remote_server_id`, `ip` | JSON status |
| `server/list_ips` | admin token | list assigned IPs | `remote_server_id` | JSON list |
| `server/edit_ip` | admin token | edit assigned IP | `remote_server_id`, `old_ip`, `new_ip` | JSON status |
| `user_games/list_games` | token | list game configs | `system`, `architecture` | JSON list |
| `user_games/list_servers` | token | list homes visible to token | none | JSON list |
| `user_games/create` | admin token | create game home | remote server, config, port, passwords, slots, affinity, nice | JSON status |
| `user_games/clone` | admin token | clone home | origin + new home fields | JSON status |
| `user_games/set_expiration` | admin token | change home expiry | `home_id`, `timestamp` | JSON status |
| `user_admin/*` | admin token | user CRUD and assignments | varies | JSON status |
| `gamemanager_admin/reorder` | admin token | reorder homes in UI | token | JSON status |
| `setting/get` | token | read setting | `setting_name` | plain text or `-1` |
## Public Status API
Endpoint:
- `Panel/status_api.php?token=...`
Authentication:
- shared query token stored in `status_api_local.php`
Purpose:
- public, read-only node summary
- intended for lightweight dashboards or public status pages
Behavior:
- caches agent stats locally for 30 seconds
- probes agents with TCP reachability
- normalizes CPU, memory, and disk stats when available
Returns:
- JSON object with `generated_at` and `nodes[]`
## Scheduler-As-API
The scheduler does not call agents directly at runtime. It stores cron lines on the agent that usually call back into:
- `Panel/ogp_api.php?gamemanager/*`
- `Panel/ogp_api.php?server_content/run_scheduled_action`
This makes `ogp_api.php` part of the internal scheduler runtime contract.
## Website API Endpoints
### Payment Creation And Capture
| Endpoint | Auth | Purpose | Parameters | Returns |
|---|---|---|---|---|
| `Website/api/create_order.php` | storefront session / checkout context | create PayPal order | checkout/cart payload | JSON PayPal order response |
| `Website/api/capture_order.php` | storefront session / checkout context | capture approved PayPal order | order/capture payload | JSON capture result |
These are thin compatibility wrappers that dispatch into the current billing runtime selected by:
- `Website/_compat_include.php`
- `website_billing_runtime_file(...)`
### Client Error Logging
| Endpoint | Auth | Purpose | Parameters | Returns |
|---|---|---|---|---|
| `Website/api/log_error.php` | none | receive cart/client JS error payloads | JSON body | JSON `{status: logged}` or error |
Security note:
- this endpoint is intentionally open
- it writes to `Website/logs/client_errors.log`
- rate limiting is not obvious in the current implementation
### Webhooks
| Endpoint | Source | Purpose | Auth Model |
|---|---|---|---|
| `Website/webhook.php` | PayPal | verify and process payment webhook | PayPal OAuth + webhook signature verification |
| `Website/paypal/webhook.php` | PayPal | compatibility entrypoint forwarding to `Website/webhook.php` runtime | same |
| `Panel/modules/billing/webhook.php` | payment runtime compatibility | billing-side webhook entrypoint | gateway-specific |
## Security Controls
| Control | Where |
|---|---|
| token auth | `Panel/ogp_api.php` |
| host allowlist | `api_authorized.hosts`, `api_authorized.fwd_hosts`, `settings/api_hosts.php` |
| role / ownership checks | inside `api_*` handlers in `ogp_api.php` |
| webhook signature verification | `Website/webhook.php` |
## Search Coverage Used For This Document
- `rg -n "^function api_" Panel/ogp_api.php`
- `sed -n '1,240p' Panel/ogp_api.php`
- `sed -n '1,240p' Panel/status_api.php`
- `find Website/api -maxdepth 1 -type f`
- `sed -n '1,220p' Website/webhook.php`

View file

@ -68,9 +68,33 @@ Default install paths:
App ID rules:
- `workshop_app_id` must come from a Server Content template, Steam Workshop profile, or game XML.
- Game XML should declare Workshop support in the canonical `workshop_support` block.
- Do not silently use the dedicated server Steam app ID as the Workshop app ID unless a legacy profile explicitly does so.
- Arma 3 XML declares Workshop app ID `107410`; its dedicated server Steam app ID remains `233780`.
Canonical XML:
```xml
<workshop_support>
<enabled>1</enabled>
<provider>steam</provider>
<steam_app_id>107410</steam_app_id>
<workshop_app_id>107410</workshop_app_id>
<download_method>steamcmd</download_method>
<install_strategy>arma_mod_folder</install_strategy>
<install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path>
<startup_param_format>-mod={MOD_LIST}</startup_param_format>
<mod_separator>;</mod_separator>
<mod_prefix>@</mod_prefix>
<copy_keys enabled="1">
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
</workshop_support>
```
The Panel helper parser reads `workshop_support` first. Older direct tags are tolerated only as a compatibility fallback in helper code; they are not the canonical XML format.
## Database State
`server_content_workshop` tracks:
@ -140,3 +164,31 @@ Use `addonsmanager` as the main future home for:
- install history
Treat `steam_workshop` as a legacy bridge for migration only.
## Panel-Agent Contract
Phase 1 does not use the legacy `steam_workshop` XML-RPC method for the primary user workflow. Instead:
1. Panel parses customer input into numeric Workshop IDs.
2. Panel writes `{SERVER_HOME}/gsp_server_content/workshop_manifest.json`.
3. Panel stages the OS-appropriate bundled handler under `{SERVER_HOME}/gsp_server_content/scripts/workshop/`.
4. Panel invokes the handler through the existing authenticated agent `exec` RPC.
5. The handler writes `workshop_install.log` or `workshop_install_windows.log` under `gsp_server_content`.
6. Panel updates `server_content_workshop.install_state` from queued/installing to installed/failed/removed.
Important manifest fields:
- `home_id`
- `home_cfg_id`
- `game_path`
- `server_path`
- `workshop_app_id`
- `steam_app_id`
- `items`
- `item_details`
- `install_strategy`
- `target_path`
- `extra.copy_keys`
- `extra.keys_target_path`
Both bundled handlers validate numeric item IDs, keep writes under the server home, use SteamCMD, copy files into the resolved target path, and copy `.bikey` files for DayZ/Arma strategies when enabled.

View file

@ -24,6 +24,7 @@ The schema supports:
- query port calculation
- control protocol selection
- mod definitions
- Workshop / Server Content capability declarations
- custom fields
- server parameter groups
- text replacement helpers
@ -89,6 +90,50 @@ XML definitions also feed:
- reserved ports
- mod or content behavior
## Workshop / Server Content Capability
Workshop-enabled games must use the canonical `workshop_support` block. Loose top-level tags such as `workshop_app_id` are compatibility parser fallbacks only and should not be used in new game XML because schema validation is intentionally strict.
Example:
```xml
<workshop_support>
<enabled>1</enabled>
<provider>steam</provider>
<steam_app_id>107410</steam_app_id>
<workshop_app_id>107410</workshop_app_id>
<download_method>steamcmd</download_method>
<install_strategy>arma_mod_folder</install_strategy>
<install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path>
<startup_param_format>-mod={MOD_LIST}</startup_param_format>
<mod_separator>;</mod_separator>
<mod_prefix>@</mod_prefix>
<copy_keys enabled="1">
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
</workshop_support>
```
Supported `install_strategy` values:
- `game_managed_workshop`
- `steamcmd_download_only`
- `copy_to_game_root`
- `copy_to_mod_folder`
- `dayz_mod_folder`
- `arma_mod_folder`
- `config_only`
- `custom_scripted_install`
`workshop_app_id` is the Steam Workshop app ID used by `steamcmd +workshop_download_item`. It is not automatically the same as a dedicated server installer app ID. For Arma 3, Workshop content uses `107410` while the dedicated server installer remains defined on the normal mod installer entry.
The current XML schema is validated by:
```bash
php Panel/modules/config_games/tests/validate_server_configs.php
```
## Recommended Mental Model
Think of the XML system as the capability definition layer:
@ -102,4 +147,3 @@ game XML
-> docs links
-> scheduler and status hints
```

View file

@ -61,6 +61,29 @@ Current default install paths:
- Generic Steam Workshop content: `{SERVER_ROOT}/workshop/{MOD_FOLDER}`
- DayZ / Arma strategy content: `{SERVER_ROOT}/{MOD_FOLDER}` for root `@<workshop_id>` folder compatibility
Game XML fallback should use the canonical `workshop_support` block:
```xml
<workshop_support>
<enabled>1</enabled>
<provider>steam</provider>
<steam_app_id>107410</steam_app_id>
<workshop_app_id>107410</workshop_app_id>
<download_method>steamcmd</download_method>
<install_strategy>arma_mod_folder</install_strategy>
<install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path>
<startup_param_format>-mod={MOD_LIST}</startup_param_format>
<mod_separator>;</mod_separator>
<mod_prefix>@</mod_prefix>
<copy_keys enabled="1">
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
</workshop_support>
```
The Panel helper parser reads this block first and only tolerates old direct tags as an internal compatibility fallback.
SteamCMD requirements:
- Linux agents need SteamCMD available at the configured profile/template path, `STEAMCMD_PATH`, `/home/gameserver/steamcmd/steamcmd.sh`, or in `PATH`.
@ -99,3 +122,14 @@ This module is the right place for:
- install history
The old `steam_workshop` module should be treated as a deprecated compatibility layer, not the main future path.
## Validation
Relevant smoke tests:
```bash
php Panel/modules/addonsmanager/tests/workshop_helpers_test.php
php Panel/modules/config_games/tests/validate_server_configs.php
```
`validate_server_configs.php` validates every XML file under `Panel/modules/config_games/server_configs/` against `schema_server_config.xml`.

View file

@ -53,10 +53,33 @@ Game XML definitions, CLI parameter generation, mod definitions, and custom fiel
## Missing Functionality
- first-class workshop/content capability declarations
- first-class scheduler capability declarations
- richer docs metadata
## Workshop Capability Declarations
The XML schema now supports a first-class `workshop_support` block for Steam Workshop / Server Content Manager metadata. This is the only canonical XML format for Workshop-enabled game configs.
Key fields:
- `enabled`
- `provider`
- `steam_app_id`
- `workshop_app_id`
- `download_method`
- `install_strategy`
- `install_path`
- `startup_param_format`
- `mod_separator`
- `mod_prefix`
- `copy_keys`
Validate changes with:
```bash
php Panel/modules/config_games/tests/validate_server_configs.php
```
## Suggested Future Improvements
- extend XML capability model
@ -66,4 +89,3 @@ Game XML definitions, CLI parameter generation, mod definitions, and custom fiel
## Recommendation
- Keep / Improve

@ -0,0 +1 @@
Subproject commit aae0acb6e11947d02157d0c9d69561843d7d3cba