From 2a6c8440aaa2d389116b67c1651ce336c4d4ec60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 14:16:41 +0000 Subject: [PATCH] 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> --- CHANGELOG.md | 2 + docs/COPILOT_TODO.md | 1 + modules/billing/api/capture_order.php | 9 +-- modules/billing/checkout_free.php | 7 +- modules/billing/create_servers.php | 97 ++++++++++++++++++++++---- modules/billing/paypal/webhook.php | 6 +- modules/gamemanager/server_monitor.php | 21 ++++++ modules/user_games/add_home.php | 10 ++- 8 files changed, 130 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34617059..64cd67f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/COPILOT_TODO.md b/docs/COPILOT_TODO.md index 3ab73879..fb603535 100644 --- a/docs/COPILOT_TODO.md +++ b/docs/COPILOT_TODO.md @@ -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. diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php index a0f6b2cd..86203ff2 100644 --- a/modules/billing/api/capture_order.php +++ b/modules/billing/api/capture_order.php @@ -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 { diff --git a/modules/billing/checkout_free.php b/modules/billing/checkout_free.php index b5453fbd..aa9e3709 100644 --- a/modules/billing/checkout_free.php +++ b/modules/billing/checkout_free.php @@ -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; + } } } } diff --git a/modules/billing/create_servers.php b/modules/billing/create_servers.php index b0e6968f..e9c9308e 100644 --- a/modules/billing/create_servers.php +++ b/modules/billing/create_servers.php @@ -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; } diff --git a/modules/billing/paypal/webhook.php b/modules/billing/paypal/webhook.php index 0fcab576..16d27d42 100644 --- a/modules/billing/paypal/webhook.php +++ b/modules/billing/paypal/webhook.php @@ -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 diff --git a/modules/gamemanager/server_monitor.php b/modules/gamemanager/server_monitor.php index 44270fa8..dc6cbb4c 100644 --- a/modules/gamemanager/server_monitor.php +++ b/modules/gamemanager/server_monitor.php @@ -446,6 +446,27 @@ echo "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(); diff --git a/modules/user_games/add_home.php b/modules/user_games/add_home.php index 4650fd03..45c784c5 100644 --- a/modules/user_games/add_home.php +++ b/modules/user_games/add_home.php @@ -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&p=edit&home_id=$new_home_id", 0); }else{