fix: auto-run and harden billing provisioning idempotency

Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/a39ca073-858c-4e1e-978f-09caabb0f029

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-09 14:16:41 +00:00 committed by GitHub
parent d636f65647
commit 2a6c8440aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 130 additions and 23 deletions

View file

@ -1,6 +1,8 @@
# Changelog
## 2026-05-09
- **Billing provisioning auto-run + idempotency hardening:** Updated paid capture, free checkout, PayPal webhook renewals, and admin add-home registration to always hand activated orders to `billing_invoke_provision()` in trusted internal context. `modules/billing/create_servers.php` now enforces 6-character alphanumeric default passwords, defaults new homes to `ftp_status=1`, adds stricter service/node/port/mod validation logging, retries existing `home_id` installs only when incomplete, and skips already-installed homes to prevent duplicate provisioning on refresh/retry paths.
- **Game Monitor IP:PORT fallback reliability:** `modules/gamemanager/server_monitor.php` now resolves missing display/connect endpoints from `home_ip_ports` + `remote_server_ips` for each home so newly provisioned servers consistently show IP:PORT even when initial joined row data is incomplete.
- **Billing docs + auto-provision/install reliability:** Updated Game Monitor and Support documentation links to always open `https://gameservers.world/docs/` (game-specific path when available) in a new tab, and hardened billing provisioning so existing `home_id` orders can retry install automatically via the shared gamemanager update trigger (no manual Update Server click required). Added structured provisioning logs (`modules/billing/logs/provisioning.log`) and enriched panel log context with order/invoice/user/home/home_cfg/mod/ip/port/mechanism/result/error fields; PayPal webhook renewals now auto-provision when an order still has no `home_id`.
- **Payment success provisioning visibility:** Expanded `modules/billing/payment_success.php` to show per-order provisioning state (install started/pending/failed) using live order/home/IP-port/mod consistency checks, so users/admins can immediately see provisioning outcomes instead of silent failures.

View file

@ -11,3 +11,4 @@
- Add an automated billing provisioning integration test fixture that verifies arrange_ports exact/fallback allocation, duplicate-port protection, and home_id linkage after paid/free checkout.
- Add a billing UI badge/filter that distinguishes "pending install" vs "installed" states directly in customer/server order views.
- Add an admin billing orders "provisioning details" drawer that reads `modules/billing/logs/provisioning.log` and shows the latest mechanism/result/error per order without leaving the panel.
- Add an automated end-to-end check that verifies `create_servers.php` skips already-installed homes while still retrying existing-home orders with missing executable/IP-port/mod prerequisites.

View file

@ -322,11 +322,10 @@ foreach ($invoices as $inv) {
$repo->updateInvoiceFields($invoiceId, ['home_id' => $currentHomeId]);
}
$ordersCreated++;
// Queue for provisioning only if not yet provisioned (home_id still '0' / empty).
if ($currentHomeId <= 0) {
if (!in_array($orderId, $newOrderIds, true)) {
$newOrderIds[] = $orderId;
cap_log('ORDER_QUEUED_PROVISION', ['order_id' => $orderId]);
}
cap_log('ORDER_QUEUED_PROVISION', ['order_id' => $orderId, 'home_id' => $currentHomeId]);
}
} else {
// No billing_orders row yet — create one now so the provisioner can run.
@ -353,7 +352,9 @@ foreach ($invoices as $inv) {
// Link invoice → order so retried captures are idempotent.
$repo->updateInvoiceOrderId($invoiceId, $newOrderId);
$repo->updateInvoiceFields($invoiceId, ['order_id' => $newOrderId]);
$newOrderIds[] = $newOrderId;
if (!in_array($newOrderId, $newOrderIds, true)) {
$newOrderIds[] = $newOrderId;
}
$ordersCreated++;
cap_log('ORDER_CREATED', ['invoice_id' => $invoiceId, 'order_id' => $newOrderId]);
} else {

View file

@ -180,7 +180,8 @@ foreach ($invoices as $inv) {
]);
if ($currentHomeId > 0) {
$repo->updateInvoiceFields($invoiceId, ['home_id' => $currentHomeId]);
} else {
}
if (!in_array($orderId, $newOrderIds, true)) {
$newOrderIds[] = $orderId;
}
} else {
@ -206,7 +207,9 @@ foreach ($invoices as $inv) {
if ($newOrderId > 0) {
$repo->updateInvoiceOrderId($invoiceId, $newOrderId);
$repo->updateInvoiceFields($invoiceId, ['order_id' => $newOrderId]);
$newOrderIds[] = $newOrderId;
if (!in_array($newOrderId, $newOrderIds, true)) {
$newOrderIds[] = $newOrderId;
}
}
}
}

View file

@ -14,16 +14,33 @@ if (!defined('BILLING_NICE_DEFAULT')) {
}
if (!function_exists('billing_generate_provision_password')) {
function billing_generate_provision_password(int $bytes = 12)
function billing_generate_provision_password(int $length = 6)
{
$length = max(6, $length);
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$alphabetLen = strlen($alphabet);
$password = '';
try {
return substr(bin2hex(random_bytes($bytes)), 0, $bytes * 2);
for ($i = 0; $i < $length; $i++) {
$password .= $alphabet[random_int(0, $alphabetLen - 1)];
}
return $password;
} catch (Throwable $e) {
return substr(hash('sha256', uniqid('gsp-provision', true) . microtime(true)), 0, $bytes * 2);
for ($i = 0; $i < $length; $i++) {
$password .= $alphabet[mt_rand(0, $alphabetLen - 1)];
}
return $password;
}
}
}
if (!function_exists('billing_is_valid_provision_password')) {
function billing_is_valid_provision_password($value): bool
{
return is_string($value) && preg_match('/^[A-Za-z0-9]{6}$/', $value) === 1;
}
}
if (!function_exists('billing_invoke_provision')) {
function billing_invoke_provision(array $options = array())
{
@ -63,6 +80,8 @@ if (!function_exists('billing_allocate_home_port')) {
return array('ok' => false, 'error' => "No IP addresses are configured for remote server #{$remote_server_id}.");
}
$ipsWithNoRange = array();
$ipsExhausted = array();
foreach ($ipIds as $ipId) {
$ranges = $db->resultQuery(
"SELECT start_port, end_port, port_increment
@ -81,6 +100,7 @@ if (!function_exists('billing_allocate_home_port')) {
);
}
if (empty($ranges)) {
$ipsWithNoRange[] = $ipId;
continue;
}
@ -133,9 +153,13 @@ if (!function_exists('billing_allocate_home_port')) {
}
}
}
$ipsExhausted[] = $ipId;
}
return array('ok' => false, 'error' => "No available port in arrange_ports for remote server #{$remote_server_id} and home_cfg_id #{$home_cfg_id}.");
if (!empty($ipsWithNoRange) && count($ipsWithNoRange) === count($ipIds)) {
return array('ok' => false, 'error' => "No port range found for home_cfg_id #{$home_cfg_id} on ip_id(s) [" . implode(',', $ipsWithNoRange) . "] for remote server #{$remote_server_id}.");
}
return array('ok' => false, 'error' => "No available port in arrange_ports for remote server #{$remote_server_id}, home_cfg_id #{$home_cfg_id}, ip_id(s) [" . implode(',', !empty($ipsExhausted) ? $ipsExhausted : $ipIds) . "].");
}
}
@ -148,11 +172,13 @@ if (!function_exists('billing_resolve_mod_cfg_id')) {
}
$first = null;
$availableModCfgIds = array();
foreach ((array)$mods as $mod) {
$modCfgId = intval($mod['mod_cfg_id'] ?? 0);
if ($modCfgId <= 0) {
continue;
}
$availableModCfgIds[] = $modCfgId;
if ($first === null) {
$first = $modCfgId;
}
@ -165,7 +191,7 @@ if (!function_exists('billing_resolve_mod_cfg_id')) {
return array('ok' => true, 'mod_cfg_id' => $first);
}
return array('ok' => false, 'error' => "No usable mod_cfg_id found for home_cfg_id #{$home_cfg_id}.");
return array('ok' => false, 'error' => "No usable mod_cfg_id found for home_cfg_id #{$home_cfg_id}. Available mod_cfg_id values: [" . implode(',', $availableModCfgIds) . "].");
}
}
@ -282,10 +308,10 @@ function exec_ogp_module()
$home_name = $order['home_name'];
$remote_control_password = $order['remote_control_password'];
$ftp_password = $order['ftp_password'];
if ($remote_control_password === '' || strcasecmp((string)$remote_control_password, 'ChangeMe') === 0) {
if (!billing_is_valid_provision_password($remote_control_password) || strcasecmp((string)$remote_control_password, 'ChangeMe') === 0) {
$remote_control_password = billing_generate_provision_password();
}
if ($ftp_password === '' || strcasecmp((string)$ftp_password, 'ChangeMe') === 0) {
if (!billing_is_valid_provision_password($ftp_password) || strcasecmp((string)$ftp_password, 'ChangeMe') === 0) {
$ftp_password = billing_generate_provision_password();
}
$ip = $order['ip'];
@ -302,6 +328,7 @@ function exec_ogp_module()
$install_result = 'pending';
$install_message = '';
$install_attempted = false;
$needs_existing_home_retry = false;
$home_info = array();
$invoiceRow = $db->resultQuery(
"SELECT invoice_id
@ -330,6 +357,14 @@ function exec_ogp_module()
$install_method = $service[0]['install_method'];
$manual_url = $service[0]['manual_url'];
$access_rights = $service[0]['access_rights'];
if (intval($home_cfg_id) <= 0) {
$order_failed = true;
$order_failure_reason = "Invalid home_cfg_id '{$home_cfg_id}' for service_id {$service_id}.";
}
if (!$order_failed && intval($remote_server_id) <= 0) {
$order_failed = true;
$order_failure_reason = "Invalid remote server selection '{$remote_server_id}' on order #{$order_id} for service_id {$service_id}.";
}
}
else
{
@ -351,6 +386,29 @@ function exec_ogp_module()
$selected_ip_id = intval($existingIpPort['ip_id']);
$selected_port = intval($existingIpPort['port']);
}
$has_ip_port = !empty($existingIpPort['ok']);
$has_mods = !empty($home_info['mods']) && is_array($home_info['mods']);
if (!$order_failed && (!$has_ip_port || !$has_mods)) {
$needs_existing_home_retry = true;
$install_message = "Existing home #{$home_id} requires provisioning completion (ip_port=" . ($has_ip_port ? 'yes' : 'no') . ", mods=" . ($has_mods ? 'yes' : 'no') . ").";
}
if (!$order_failed && !$needs_existing_home_retry) {
$server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']);
if ($server_xml && !empty((string)$server_xml->server_exec_name)) {
$remote = new OGPRemoteLibrary($home_info['agent_ip'],$home_info['agent_port'],$home_info['encryption_key'],$home_info['timeout']);
if ($remote->status_chk() === 1) {
$exec_path = clean_path($home_info['home_path'] . "/" . (string)$server_xml->exe_location . "/" . (string)$server_xml->server_exec_name);
if ($remote->rfile_exists($exec_path) !== 1) {
$needs_existing_home_retry = true;
$install_message = "Existing home #{$home_id} missing expected executable at {$exec_path}; retrying install.";
}
}
}
}
if (!$order_failed && !$needs_existing_home_retry) {
$install_result = 'already_provisioned';
$install_message = $install_message !== '' ? $install_message : "Order #{$order_id} already provisioned and installed; no action required.";
}
}
elseif(!$order_failed && $extended)
{
@ -408,12 +466,21 @@ function exec_ogp_module()
//Add Game home to database
//HARD CODE TO /home/gameserver/
$rserver = $db->getRemoteServer($remote_server_id);
if (empty($rserver)) {
$order_failed = true;
$order_failure_reason = "Remote server #{$remote_server_id} not found for order #{$order_id} (service_id {$service_id}).";
}
$game_path = "/home/gameserver/";
$home_id = $db->addGameHome( $remote_server_id, $user_id, $home_cfg_id, $game_path, $home_name, $remote_control_password, $ftp_password);
if (!$home_id || intval($home_id) <= 0) {
if (!$order_failed) {
$home_id = $db->addGameHome( $remote_server_id, $user_id, $home_cfg_id, $game_path, $home_name, $remote_control_password, $ftp_password);
}
if (!$order_failed && (!$home_id || intval($home_id) <= 0)) {
$order_failed = true;
$order_failure_reason = "Could not create server_homes row for order #{$order_id}.";
}
if (!$order_failed) {
$db->changeFtpStatus('enabled', intval($home_id));
}
// Add IP:Port pair with arrange_ports exact home_cfg_id preference and home_cfg_id=0 fallback.
if (!$order_failed) {
@ -514,8 +581,11 @@ function exec_ogp_module()
$install_result = 'pending';
}
if (empty($autoInstall['ok'])) {
if (stripos((string)($autoInstall['message'] ?? ''), 'Agent is offline') !== false) {
$order_failure_reason = "Agent is offline for remote server #{$remote_server_id} (" . ($home_info['agent_ip'] ?? 'unknown') . ":" . ($home_info['agent_port'] ?? 'unknown') . ").";
}
$order_failed = true;
$order_failure_reason = "Server files have not been installed yet. " . ($autoInstall['message'] ?? 'Auto install could not be started.');
$order_failure_reason = $order_failure_reason !== '' ? $order_failure_reason : ("Server files have not been installed yet. " . ($autoInstall['message'] ?? 'Auto install could not be started.'));
$install_result = 'failed';
$install_message = $order_failure_reason;
}
@ -555,7 +625,7 @@ function exec_ogp_module()
}
// Retry install for orders that already have home_id but never triggered installation.
if (!$order_failed && !$extended && !$install_attempted && intval($home_id) > 0) {
if (!$order_failed && !$extended && !$install_attempted && intval($home_id) > 0 && (!$alreadyProvisioned || $needs_existing_home_retry)) {
if ($selected_ip_id <= 0 || $selected_port <= 0) {
$existingIpPort = billing_get_home_ip_port($db, $db_prefix, intval($home_id));
if (!empty($existingIpPort['ok'])) {
@ -628,8 +698,11 @@ function exec_ogp_module()
$install_result = 'pending';
}
if (empty($autoInstall['ok'])) {
if (stripos((string)($autoInstall['message'] ?? ''), 'Agent is offline') !== false) {
$order_failure_reason = "Agent is offline for remote server #{$remote_server_id} (" . ($home_info['agent_ip'] ?? 'unknown') . ":" . ($home_info['agent_port'] ?? 'unknown') . ").";
}
$order_failed = true;
$order_failure_reason = "Server files have not been installed yet. " . ($autoInstall['message'] ?? 'Auto install could not be started.');
$order_failure_reason = $order_failure_reason !== '' ? $order_failure_reason : ("Server files have not been installed yet. " . ($autoInstall['message'] ?? 'Auto install could not be started.'));
$install_result = 'failed';
$install_message = $order_failure_reason;
}

View file

@ -558,10 +558,8 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil
$last_order_id = $order_id;
$existing_home_id = intval($row['home_id'] ?? 0);
wh_log('info', 'order_renewed', ['order_id' => $order_id, 'new_end' => $new_end, 'home_id' => $existing_home_id]);
if ($existing_home_id <= 0) {
$dir = ($billing_dir !== '') ? $billing_dir : dirname(__DIR__);
wh_try_provision($dir, $order_id, $user_id);
}
$dir = ($billing_dir !== '') ? $billing_dir : dirname(__DIR__);
wh_try_provision($dir, $order_id, $user_id);
}
} else {
// New order: create billing_orders row

View file

@ -446,6 +446,27 @@ echo "<table id='servermonitor' class='tablesorter' data-sortlist='[[0,0],[3,1]]
$btns = get_monitor_buttons($server_home, $server_xml);
//End
if (empty($server_home['ip']) || empty($server_home['port'])) {
$home_ip_ports = $db->getHomeIpPorts(intval($server_home['home_id']));
if (!empty($home_ip_ports) && is_array($home_ip_ports)) {
$fallback_ip_port = null;
foreach ((array)$home_ip_ports as $ip_port_row) {
if (intval($ip_port_row['force_mod_id'] ?? 0) === intval($server_home['mod_id'] ?? 0)) {
$fallback_ip_port = $ip_port_row;
break;
}
if ($fallback_ip_port === null && intval($ip_port_row['force_mod_id'] ?? 0) === 0) {
$fallback_ip_port = $ip_port_row;
}
}
if ($fallback_ip_port === null) {
$fallback_ip_port = $home_ip_ports[0];
}
$server_home['ip'] = $fallback_ip_port['ip'] ?? $server_home['ip'];
$server_home['port'] = $fallback_ip_port['port'] ?? $server_home['port'];
$server_home['ip_id'] = $fallback_ip_port['ip_id'] ?? ($server_home['ip_id'] ?? 0);
}
}
$remote = new OGPRemoteLibrary($server_home['agent_ip'], $server_home['agent_port'], $server_home['encryption_key'], $server_home['timeout']);
$host_stat = $remote->status_chk();

View file

@ -155,7 +155,7 @@ function exec_ogp_module()
// Record the server in the billing tables so it participates in the
// normal lifecycle (renewals, expiration, admin dashboard).
require_once('billing_integration.php');
admin_register_server_in_billing(
$billing_order_id = admin_register_server_in_billing(
$db,
$web_user_id,
$home_cfg_id,
@ -168,6 +168,14 @@ function exec_ogp_module()
$control_password,
$ftppassword
);
if ($billing_order_id !== FALSE && intval($billing_order_id) > 0) {
require_once(__DIR__ . '/../billing/create_servers.php');
billing_invoke_provision(array(
'order_ids' => array(intval($billing_order_id)),
'user_id' => intval($web_user_id),
'is_admin' => true
));
}
$view->refresh("?m=user_games&amp;p=edit&amp;home_id=$new_home_id", 0);
}else{