Files
CLOAK/lib/Cloak/VisitorSimulationRunner.php
T
2026-06-16 04:58:56 +08:00

592 lines
20 KiB
PHP
Executable File

<?php
/**
* 模拟测试访问:重建请求环境并跑完整判定链(含二次风控)
*/
require_once dirname(__DIR__, 2) . '/config/ConfigLoader.php';
require_once __DIR__ . '/SimulationRouteResolver.php';
require_once __DIR__ . '/FCheckHandler.php';
require_once __DIR__ . '/DomainRepository.php';
class VisitorSimulationRunner
{
const REASON_PREFIX = '[模拟测试]';
/**
* @param array $input
* @return array
*/
public static function runManual(array $input)
{
$context = self::normalizeManualInput($input);
if (!empty($context['error'])) {
return ['ok' => false, 'message' => $context['error']];
}
return self::execute($context, null);
}
/**
* @param int $logId
* @return array
*/
public static function replayFromLog($logId)
{
$row = self::fetchLogRow((int) $logId);
if ($row === null) {
return ['ok' => false, 'message' => '日志不存在'];
}
$context = self::contextFromLogRow($row);
if (!empty($context['error'])) {
return ['ok' => false, 'message' => $context['error']];
}
return self::execute($context, (int) $logId);
}
/**
* @param int $logId
* @return array
*/
public static function prefillFromLog($logId)
{
$row = self::fetchLogRow((int) $logId);
if ($row === null) {
return ['ok' => false, 'message' => '日志不存在'];
}
$context = self::contextFromLogRow($row);
if (!empty($context['error'])) {
return ['ok' => false, 'message' => $context['error']];
}
return [
'ok' => true,
'log_id' => (int) $logId,
'prefill' => [
'config_name' => $context['config_name'],
'ip' => $context['ip'],
'domain' => $context['domain'],
'page_url' => $context['page_url'],
'referer' => $context['referer'],
'user_agent' => $context['user_agent'],
'accept_language' => $context['accept_language'],
'visit_md5_code' => $context['visit_md5_code'],
'cookies_json' => $context['cookies_json'],
'fp_hdata' => $context['fp_hdata_raw'],
'device' => $context['device_override'],
],
];
}
/**
* @param string $reason
* @return string
*/
public static function tagReason($reason)
{
$reason = trim((string) $reason);
if ($reason === '') {
return self::REASON_PREFIX;
}
if (strpos($reason, self::REASON_PREFIX) === 0) {
return $reason;
}
return self::REASON_PREFIX . ' ' . $reason;
}
/**
* @param array $input
* @return array
*/
private static function normalizeManualInput(array $input)
{
$configName = ConfigLoader::sanitizeConfigName($input['config_name'] ?? '');
if (trim((string) ($input['config_name'] ?? '')) === '') {
return ['error' => '请选择配置'];
}
$domain = self::normalizeHost($input['domain'] ?? '');
if ($domain === '') {
$domain = self::defaultHostForConfig($configName);
}
if ($domain === '') {
return ['error' => '请填写域名或绑定站点域名'];
}
$pageUrl = trim((string) ($input['page_url'] ?? ''));
if ($pageUrl === '') {
$pageUrl = 'https://' . $domain . '/' . $configName . '.php';
}
$cookies = [];
if (!empty($input['cookies_json'])) {
$decoded = json_decode((string) $input['cookies_json'], true);
if (!is_array($decoded)) {
return ['error' => 'Cookie JSON 格式无效'];
}
$cookies = $decoded;
}
$fpRaw = trim((string) ($input['fp_hdata'] ?? ''));
return [
'config_name' => $configName,
'ip' => trim((string) ($input['ip'] ?? '8.8.8.8')),
'domain' => $domain,
'page_url' => $pageUrl,
'referer' => trim((string) ($input['referer'] ?? '')),
'user_agent' => trim((string) ($input['user_agent'] ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')),
'accept_language' => trim((string) ($input['accept_language'] ?? 'en-US,en;q=0.9')),
'visit_md5_code' => trim((string) ($input['visit_md5_code'] ?? '')),
'cookies' => $cookies,
'cookies_json' => (string) ($input['cookies_json'] ?? ''),
'fp_hdata_raw' => $fpRaw,
'fp_hdata' => self::parseFpHdata($fpRaw),
'device_override' => trim((string) ($input['device'] ?? '')),
];
}
/**
* @param array $row
* @return array
*/
private static function contextFromLogRow(array $row)
{
$domain = self::normalizeHost($row['domain'] ?? '');
$pageUrl = trim((string) ($row['page'] ?? ''));
$config = self::guessConfigName($pageUrl, $domain);
if ($config === '' && $domain !== '') {
try {
$pdo = DomainRepository::pdo();
DomainRepository::ensureTable($pdo);
$domRow = DomainRepository::findByHostname($pdo, $domain);
if ($domRow && !empty($domRow['config_name'])) {
$config = trim($domRow['config_name']);
}
} catch (Throwable $e) {
}
}
if ($config === '') {
$config = 'index';
}
$referer = trim((string) ($row['http_referer'] ?? ''));
if ($referer === '') {
$referer = trim((string) ($row['referer'] ?? ''));
}
$visitCode = trim((string) ($row['visit_md5_code'] ?? ''));
$cookies = [];
if ($visitCode !== '' && $visitCode !== 'loss') {
$cookies['gfuid'] = $visitCode;
}
$fpRaw = trim((string) ($row['fp_hdata'] ?? ''));
return [
'config_name' => $config,
'ip' => trim((string) ($row['IP'] ?? '')),
'domain' => $domain !== '' ? $domain : self::defaultHostForConfig($config),
'page_url' => $pageUrl !== '' ? $pageUrl : ('https://' . ($domain ?: 'localhost') . '/' . $config . '.php'),
'referer' => $referer,
'user_agent' => trim((string) ($row['user_agent'] ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')),
'accept_language' => trim((string) ($row['accept_language_raw'] ?? $row['language'] ?? 'en-US,en;q=0.9')),
'visit_md5_code' => $visitCode,
'cookies' => $cookies,
'cookies_json' => $cookies !== [] ? json_encode($cookies, JSON_UNESCAPED_UNICODE) : '',
'fp_hdata_raw' => $fpRaw,
'fp_hdata' => self::parseFpHdata($fpRaw),
'device_override' => trim((string) ($row['device'] ?? '')),
];
}
/**
* @param array $context
* @param int|null $sourceLogId
* @return array
*/
private static function execute(array $context, $sourceLogId)
{
if (!defined('DB_USERNAME')) {
require_once dirname(__DIR__, 2) . '/cong.php';
}
require_once dirname(__DIR__, 2) . '/lib/bootstrap.php';
$backup = self::backupGlobals();
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$_SESSION = [];
self::injectEnvironment($context);
$configName = ConfigLoader::loadByName($context['config_name']);
$GLOBALS['__cloak_simulation_on'] = true;
$GLOBALS['__cloak_force_sync_log'] = true;
$GLOBALS['__cloak_debug_on'] = false;
CloakPipelineTimer::init();
global $v_info, $reason, $config_name;
$config_name = $configName;
$v_info = new visitorInfo();
$v_info->get_visitor_Info(COSTM_IP_SCORE, CHECK_KEY, SHOW_SITE_COUNTRY);
$reason = '';
CheckPipeline::run();
if (!isset($_SESSION['check_result']) || $_SESSION['check_result'] === '') {
$_SESSION['check_result'] = 'false';
if ($reason === '') {
$reason = '未产生判定结果,默认安全页';
}
}
$reason = cloak_finalize_reason($reason, $_SESSION['check_result']);
$model = self::detectDevice($context);
$logs = self::buildLogs($v_info, $reason, $model, $context, $sourceLogId);
$route = SimulationRouteResolver::dispatch([
'v_info' => $v_info,
'logs' => $logs,
'config_name' => $configName,
'reason' => $reason,
'model' => $model,
]);
$secondary = null;
if (!empty($route['needs_secondary'])) {
if (!empty($context['fp_hdata']) && is_array($context['fp_hdata'])) {
$secondary = self::runSecondary((int) ($route['log_id'] ?? 0), $context);
} else {
$secondary = [
'skipped' => true,
'message' => '命中二次风控但未提供 fp_hdata,日志保持 wait 状态',
];
}
}
$timing = CloakPipelineTimer::finish();
return [
'ok' => true,
'new_log_id' => $route['log_id'] ?? null,
'sim_source' => $sourceLogId,
'primary' => [
'result' => $_SESSION['check_result'] ?? '',
'reason' => $reason,
'branch' => $route['branch'] ?? '',
],
'secondary' => $secondary,
'timing' => $timing,
];
} catch (Throwable $e) {
return [
'ok' => false,
'message' => $e->getMessage(),
];
} finally {
self::restoreGlobals($backup);
}
}
/**
* @param int $logId
* @param array $context
* @return array|null
*/
private static function runSecondary($logId, array $context)
{
if ($logId <= 0) {
return ['ok' => false, 'message' => '无效 log_id,无法执行二次风控'];
}
$fp = $context['fp_hdata'];
if (!is_array($fp)) {
return ['ok' => false, 'message' => 'fp_hdata 无效'];
}
$scheme = 'https://';
$host = $context['domain'];
$fp['log_id'] = $logId;
$fp['ip'] = $context['ip'];
$fp['referer'] = $context['referer'];
if (empty($fp['domain'])) {
$fp['domain'] = $scheme . $host . '/';
}
$result = FCheckHandler::process($fp);
if (empty($result['ok'])) {
return [
'ok' => false,
'message' => $result['message'] ?? '二次风控失败',
];
}
if (!empty($GLOBALS['__cloak_simulation_on']) && isset($result['reason'])) {
self::tagReasonOnLog($logId, (string) $result['reason']);
}
return [
'ok' => true,
'result' => !empty($result['result']),
'reason' => $result['reason'] ?? '',
'link' => $result['link'] ?? '',
];
}
/**
* @param int $logId
* @param string $reason
* @param bool $passed
*/
private static function tagReasonOnLog($logId, $reason)
{
$conn = @new mysqli('localhost', DB_USERNAME, DB_PASSWORD, DB_NAME);
if (!$conn || $conn->connect_error) {
return;
}
$reasonEsc = cloak_db_escape($conn, self::tagReason($reason));
$conn->query("UPDATE `visitor_logs` SET `reason`='{$reasonEsc}' WHERE `id`=" . (int) $logId);
$conn->close();
}
/**
* @param array $context
*/
private static function injectEnvironment(array $context)
{
$host = $context['domain'];
$pageUrl = $context['page_url'];
$parts = parse_url($pageUrl);
$path = $parts['path'] ?? ('/' . $context['config_name'] . '.php');
$query = $parts['query'] ?? '';
$_GET = [];
if ($query !== '') {
parse_str($query, $_GET);
}
$_REQUEST = $_GET;
$_COOKIE = is_array($context['cookies']) ? $context['cookies'] : [];
$visitCode = $context['visit_md5_code'];
if ($visitCode === '' || $visitCode === 'loss') {
$visitCode = md5($context['ip'] . microtime(true));
}
$_COOKIE['gfuid'] = $visitCode;
$_SESSION['gcuid'] = $visitCode;
$_SESSION['gcu_ip'] = $context['ip'];
$_SERVER['HTTP_HOST'] = $host;
$_SERVER['SERVER_NAME'] = $host;
$_SERVER['REMOTE_ADDR'] = $context['ip'];
$_SERVER['HTTP_USER_AGENT'] = $context['user_agent'];
$_SERVER['HTTP_REFERER'] = $context['referer'];
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = $context['accept_language'];
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_PORT'] = '443';
$_SERVER['REQUEST_URI'] = $path . ($query !== '' ? '?' . $query : '');
$_SERVER['QUERY_STRING'] = $query;
$_SERVER['PHP_SELF'] = $path;
$_SERVER['SCRIPT_NAME'] = $path;
}
/**
* @param visitorInfo $v_info
* @param string $reason
* @param string $model
* @param array $context
* @param int|null $sourceLogId
* @return array
*/
private static function buildLogs($v_info, $reason, $model, array $context, $sourceLogId)
{
$logs = [
'site_name' => str_replace('www.', '', $context['domain']),
'customers_ip' => $v_info->v_ip,
'country' => substr((string) $v_info->v_country, 0, 2),
'v_Browser' => $v_info->v_Browser,
'v_referer' => $v_info->v_referer,
'Client' => $v_info->v_Client,
'v_PageURL' => $v_info->v_curPageURL,
'accept_language' => $v_info->accept_language,
'visit_md5_code' => $_SESSION['gcuid'] ?? $v_info->visit_md5_code,
'campagin_id' => $v_info->costm_ip_score,
'result' => $_SESSION['check_result'] ?? 'false',
'device' => $model,
'reason' => self::tagReason($reason),
'user_agent' => $context['user_agent'],
'http_referer' => $context['referer'],
'accept_language_raw' => $context['accept_language'],
'is_simulation' => 1,
];
if ($sourceLogId) {
$logs['sim_source_log_id'] = (int) $sourceLogId;
}
return $logs;
}
/**
* @param array $context
* @return string
*/
private static function detectDevice(array $context)
{
if ($context['device_override'] !== '') {
return $context['device_override'];
}
$uaPath = dirname(__DIR__, 2) . '/Mobile-Detect/src/MobileDetect.php';
if (!is_file($uaPath)) {
return 'PC';
}
require_once $uaPath;
$detect = new \Detection\MobileDetect;
$deviceType = ($detect->isMobile() ? ($detect->isTablet() ? 'tablet' : 'phone') : 'computer');
if ($deviceType === 'computer') {
return 'PC';
}
$patterns = $deviceType === 'tablet' ? $detect::getTabletDevices() : $detect::getPhoneDevices();
$model = ucfirst($deviceType);
foreach ($patterns as $brand => $pattern) {
if (preg_match('/' . $pattern . '/i', $context['user_agent'], $matches)) {
$model = $brand . ' ' . (isset($matches[1]) ? $matches[1] : '');
break;
}
}
return trim($model) !== '' ? trim($model) : 'Mobile';
}
/**
* @param string $raw
* @return array|null
*/
private static function parseFpHdata($raw)
{
$raw = trim((string) $raw);
if ($raw === '') {
return null;
}
if ($raw[0] === '{' || $raw[0] === '[') {
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
$decodedString = base64_decode($raw, true);
if ($decodedString === false) {
return null;
}
$decoded = json_decode($decodedString, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param string $pageUrl
* @param string $domain
* @return string
*/
private static function guessConfigName($pageUrl, $domain)
{
$path = parse_url($pageUrl, PHP_URL_PATH);
if (is_string($path) && $path !== '') {
$base = basename($path, '.php');
if ($base !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $base)) {
return $base;
}
}
return '';
}
/**
* @param string $configName
* @return string
*/
private static function defaultHostForConfig($configName)
{
try {
$pdo = DomainRepository::pdo();
DomainRepository::ensureTable($pdo);
$host = DomainRepository::primaryHostnameForConfig($pdo, $configName);
if ($host !== '') {
return self::normalizeHost($host);
}
} catch (Throwable $e) {
}
return self::normalizeHost($_SERVER['HTTP_HOST'] ?? 'localhost');
}
/**
* @param string $host
* @return string
*/
private static function normalizeHost($host)
{
$host = trim(strtolower((string) $host));
$host = preg_replace('#^https?://#', '', $host);
$host = explode('/', $host)[0];
return preg_replace('/^www\./', '', $host);
}
/**
* @param int $logId
* @return array|null
*/
private static function fetchLogRow($logId)
{
if ($logId <= 0) {
return null;
}
$conn = @new mysqli('localhost', DB_USERNAME, DB_PASSWORD, DB_NAME);
if (!$conn || $conn->connect_error) {
return null;
}
VisitorLogSchema::ensureColumns($conn);
$sql = 'SELECT * FROM `visitor_logs` WHERE `id`=' . (int) $logId . ' LIMIT 1';
$res = $conn->query($sql);
if (!$res || $res->num_rows === 0) {
$conn->close();
return null;
}
$row = $res->fetch_assoc();
$res->free();
$conn->close();
return $row;
}
/**
* @return array
*/
private static function backupGlobals()
{
return [
'_SERVER' => $_SERVER,
'_GET' => $_GET,
'_REQUEST' => $_REQUEST,
'_COOKIE' => $_COOKIE,
'_SESSION' => $_SESSION,
'GLOBALS' => [
'__cloak_simulation_on' => $GLOBALS['__cloak_simulation_on'] ?? null,
'__cloak_force_sync_log' => $GLOBALS['__cloak_force_sync_log'] ?? null,
'__cloak_debug_on' => $GLOBALS['__cloak_debug_on'] ?? null,
],
];
}
/**
* @param array $backup
*/
private static function restoreGlobals(array $backup)
{
$_SERVER = $backup['_SERVER'];
$_GET = $backup['_GET'];
$_REQUEST = $backup['_REQUEST'];
$_COOKIE = $backup['_COOKIE'];
$_SESSION = $backup['_SESSION'];
foreach ($backup['GLOBALS'] as $key => $value) {
if ($value === null) {
unset($GLOBALS[$key]);
} else {
$GLOBALS[$key] = $value;
}
}
}
}