, reporting: list}, * seeds_initialized: bool, * sync_rebuilt: bool, * support_path: string, * weifile_path: ?string, * daily_path: string, * support_template?: string, * } */ public function generate( string $channelId, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE, ?string $deploymentSeed = null, ?string $reportingSeed = null, string $builderType = self::BUILDER_OLD, ): array { $builderType = $this->normalizeBuilderType($builderType); if ($builderType === self::BUILDER_NEW) { return $this->generateNew($channelId, $supportTemplate); } [$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds( $deploymentSeed, $reportingSeed, ); return $this->generateOld( $this->normalizeChannelId($channelId), $supportTemplate, $deploymentSeed, $reportingSeed, ); } public function deleteWebTree( string $channelId, string $builderType = self::BUILDER_OLD, ): void { $builderType = $this->normalizeBuilderType($builderType); if ($builderType === self::BUILDER_NEW) { $code = Channel::normalizeNewChannelId($channelId); if ($code === null) { return; } $dir = $this->newChannelDirPath($code); if (is_dir($dir) && ! $this->removeDirectory($dir)) { throw new RuntimeException('删除渠道资源失败: '.$dir); } return; } try { $channelId = $this->normalizeChannelId($channelId); } catch (RuntimeException) { return; } $cmd = [ $this->pythonBinary($builderType), $this->builderScript('delete_channel_web.py', $builderType), '--artifact-root', $this->artifactRoot(), '--channel-id', $channelId, ]; $this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType)); } /** * @return array{ * channel_id: string, * builder_type: string, * seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string}, * domains: array{deployment: list, reporting: list}, * seeds_initialized: bool, * sync_rebuilt: bool, * support_path: string, * weifile_path: ?string, * daily_path: string, * support_template?: string, * } */ private function generateOld( string $channelId, string $supportTemplate, ?string $deploymentSeed, ?string $reportingSeed, ): array { $supportTemplate = $this->normalizeSupportTemplate($supportTemplate); $cmd = [ $this->pythonBinary(self::BUILDER_OLD), $this->builderScript('new_project.py', self::BUILDER_OLD), '--artifact-root', $this->artifactRoot(), '--state-root', $this->stateRoot(self::BUILDER_OLD), '--channel-id', $channelId, '--support-template', $supportTemplate, '--force', ]; if ($deploymentSeed !== null && $reportingSeed !== null) { $cmd[] = '--deployment-seed'; $cmd[] = $deploymentSeed; $cmd[] = '--reporting-seed'; $cmd[] = $reportingSeed; } $result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD)); $supportPath = (string) ($result['support_path'] ?? '/web/'.$channelId.'/support.html'); return [ 'channel_id' => (string) ($result['channel_id'] ?? $channelId), 'builder_type' => self::BUILDER_OLD, 'seeds' => [ 'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''), 'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''), ], 'domains' => [ 'deployment' => array_values((array) data_get($result, 'domains.deployment', [])), 'reporting' => array_values((array) data_get($result, 'domains.reporting', [])), ], 'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false), 'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false), 'support_path' => $supportPath, 'weifile_path' => null, 'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'), 'support_template' => (string) ($result['support_template'] ?? $supportTemplate), ]; } /** * Rebuild shared /details and staged weifile from shared channel `c`. * Channel create always runs this with XXBB_CHANNEL_C from env (no random). * * @return array */ public function buildSharedArtifacts(?string $channelC = null, bool $randomC = false): array { if ($randomC && $channelC !== null && $channelC !== '') { throw new RuntimeException('不能同时指定 channel-c 和 random-c'); } $cmd = [ $this->pythonBinary(self::BUILDER_NEW), $this->builderScript('build.py', self::BUILDER_NEW), '--artifact-root', $this->artifactRoot(), '--state-root', $this->stateRoot(self::BUILDER_NEW), '--apply', '--force', ]; if ($randomC) { $cmd[] = '--random-c'; } else { $c = $this->normalizeSharedChannelC($channelC); if ($c === null) { throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)'); } $cmd[] = '--channel-c'; $cmd[] = $c; } return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW)); } /** * Rebuild existing new-builder channels in place (same channel_id / ver patch). * Shared /details + staged weifile are built once from XXBB_CHANNEL_C, then each * public/channel/{id}/ tree is overwritten. Channel DB rows are not changed. * * @param list|null $channelIds null = all builder_type=new rows * @return array{shared: array, channels: list>} */ public function rebuildNewChannels( ?array $channelIds = null, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE, bool $rebuildShared = true, ): array { $ids = $this->resolveNewChannelIds($channelIds); if ($ids === []) { throw new RuntimeException('没有可重打的新版渠道(builder_type=new)'); } $shared = []; if ($rebuildShared) { $shared = $this->buildSharedArtifacts($this->requireEnvChannelC(), false); } $channels = []; foreach ($ids as $id) { $channels[] = $this->generateNew($id, $supportTemplate, rebuildShared: false); } return [ 'shared' => $shared, 'channels' => $channels, ]; } /** * @param list|null $channelIds * @return list */ public function resolveNewChannelIds(?array $channelIds = null): array { if ($channelIds === null) { return Channel::query() ->where('builder_type', self::BUILDER_NEW) ->orderBy('id') ->pluck('channel_id') ->map(static fn ($id) => Channel::normalizeNewChannelId((string) $id)) ->filter() ->values() ->all(); } $ids = []; foreach ($channelIds as $raw) { $normalized = Channel::normalizeNewChannelId((string) $raw); if ($normalized === null) { throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1): '.$raw); } $ids[] = $normalized; } return array_values(array_unique($ids)); } private function generateNew( string $channelId, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE, bool $rebuildShared = true, ): array { $channelId = Channel::normalizeNewChannelId($channelId); if ($channelId === null) { throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)'); } $supportTemplate = $this->normalizeSupportTemplate($supportTemplate); // Shared details + staged weifile always rebuilt from env XXBB_CHANNEL_C (never random). if ($rebuildShared) { $this->buildSharedArtifacts($this->requireEnvChannelC(), false); } $weifileSrc = $this->stagedWeifileDir(); $detailsSrc = $this->artifactRoot().DIRECTORY_SEPARATOR.'details'; $channelOut = $this->newChannelDirPath($channelId); $this->ensureDirectory(dirname($channelOut), '新版渠道产物目录'); $cmd = [ $this->pythonBinary(self::BUILDER_NEW), $this->builderScript('pack_channel.py', self::BUILDER_NEW), '--channel-ver', $channelId, '--state-root', $this->stateRoot(self::BUILDER_NEW), '--weifile-src', $weifileSrc, '--details-src', $detailsSrc, '--channel-out', $channelOut, '--landing-template', $supportTemplate, ]; $result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW)); $seeds = $this->loadNewLabSeeds(); $landing = (string) ($result['landing_path'] ?? '/channel/'.$channelId.'/weifile/weifile.html'); $detailsPath = (string) ($result['details_path'] ?? '/channel/'.$channelId.'/details/'); return [ 'channel_id' => $channelId, 'builder_type' => self::BUILDER_NEW, 'seeds' => [ 'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', $seeds['deployment_seed']), 'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', $seeds['reporting_seed']), 'channel_c' => (string) data_get($result, 'seeds.channel_c', $seeds['channel_c']), ], 'domains' => [ 'deployment' => array_values((array) data_get($result, 'domains.deployment', $seeds['domains']['deployment'])), 'reporting' => array_values((array) data_get($result, 'domains.reporting', $seeds['domains']['reporting'])), ], 'seeds_initialized' => false, 'sync_rebuilt' => false, 'support_path' => $landing, 'weifile_path' => null, 'daily_path' => $detailsPath, 'channel_dir' => '/channel/'.$channelId, 'show_alias' => (string) ($result['show_alias'] ?? '/c/'.$channelId.'/show.htm'), 'support_template' => (string) ($result['landing_template'] ?? $supportTemplate), ]; } public function stagedWeifileDir(): string { return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile'; } public function newChannelDirPath(string $channelId): string { $code = Channel::normalizeNewChannelId($channelId) ?? $channelId; return $this->artifactRoot().DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$code; } private function removeDirectory(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)) { if (! $this->removeDirectory($path)) { return false; } } elseif (! unlink($path)) { return false; } } return rmdir($dir); } /** * @return array{deployment_seed: string, reporting_seed: string, channel_c: string, domains: array{deployment: list, reporting: list}} */ private function loadNewLabSeeds(): array { $empty = [ 'deployment_seed' => '', 'reporting_seed' => '', 'channel_c' => '', 'domains' => ['deployment' => [], 'reporting' => []], ]; $path = $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'lab_seeds.json'; if (! is_file($path)) { return $empty; } $decoded = json_decode((string) file_get_contents($path), true); if (! is_array($decoded)) { return $empty; } return [ 'deployment_seed' => (string) ($decoded['deployment_seed'] ?? ''), 'reporting_seed' => (string) ($decoded['reporting_seed'] ?? ''), 'channel_c' => (string) ($decoded['channel_c'] ?? ''), 'domains' => [ 'deployment' => array_values((array) data_get($decoded, 'domains.deployment', [])), 'reporting' => array_values((array) data_get($decoded, 'domains.reporting', [])), ], ]; } private function requireEnvChannelC(): string { $c = $this->normalizeSharedChannelC(null); if ($c === null) { throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)'); } return $c; } private function normalizeSharedChannelC(?string $channelC): ?string { $c = strtolower(trim((string) ($channelC !== null && $channelC !== '' ? $channelC : config('coruna.xxbb.channel_c', '')))); if ($c === '') { return null; } if (! preg_match('/^[0-9a-f]{32}$/', $c)) { throw new RuntimeException('XXBB_CHANNEL_C 必须是 32 位 hex'); } if ($c === '202800cfb1ad3de68e11239dcc26c30b') { throw new RuntimeException('XXBB_CHANNEL_C 不能与 7z 密码槽相同'); } return $c; } /** * @param list $cmd * @return array */ private function runBuilder(array $cmd, string $errorPrefix, string $cwd): array { $timeout = (float) config('coruna.channel_builder.timeout', 600); $probe = $this->builderProbe($cmd, $cwd); Log::info('channel_builder start', $probe + [ 'error_prefix' => $errorPrefix, 'timeout' => $timeout, ]); $started = microtime(true); $process = Process::timeout((int) max(1, $timeout)) ->path($cwd) ->run($cmd); $ms = (int) ((microtime(true) - $started) * 1000); $stdout = trim($process->output()); $stderr = trim($process->errorOutput()); if (! $process->successful()) { $hint = trim($this->builderFailHint((int) $process->exitCode(), $cmd, $cwd).' ' .$this->hardeningHint($stderr."\n".$stdout, $process->exitCode())); Log::error('channel_builder failed', $probe + [ 'error_prefix' => $errorPrefix, 'exit_code' => $process->exitCode(), 'ms' => $ms, 'stdout' => mb_substr($stdout, 0, 2000), 'stderr' => mb_substr($stderr, 0, 2000), 'hint' => $hint, ]); $detail = $stderr !== '' ? $stderr : $stdout; if ($detail === '') { $detail = 'builder exited '.$process->exitCode(); } if ($hint !== '') { $detail .= ';'.$hint; } throw new RuntimeException($errorPrefix.': '.mb_substr($detail, 0, 2500)); } Log::info('channel_builder ok', [ 'error_prefix' => $errorPrefix, 'ms' => $ms, 'cwd' => $cwd, 'python' => $cmd[0] ?? '', ]); return $this->parseResultMarker($process->output(), $errorPrefix); } /** * @param list $cmd * @return array */ private function builderProbe(array $cmd, string $cwd): array { $python = (string) ($cmd[0] ?? ''); $script = (string) ($cmd[1] ?? ''); $uid = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid(); $user = function_exists('posix_getpwuid') ? ((posix_getpwuid((int) $uid)['name'] ?? null) ?: (string) $uid) : (string) $uid; return [ 'cwd' => $cwd, 'cwd_exists' => is_dir($cwd), 'cmd' => $cmd, 'php_user' => $user, 'php_uid' => $uid, 'python' => $python, 'python_is_abs' => $python !== '' && $python[0] === '/', 'python_is_link' => $python !== '' && $this->pathIsLink($python), 'python_is_file' => $python !== '' && @is_file($python), 'python_link' => $python !== '' ? ($this->symlinkTarget($python) ?: null) : null, 'script' => $script, 'script_exists' => $script !== '' && is_file($script), 'path_env' => (string) (getenv('PATH') ?: ''), ]; } /** * @param list $cmd */ private function builderFailHint(int $exit, array $cmd, string $cwd): string { if ($exit !== 127) { return ''; } $python = (string) ($cmd[0] ?? ''); $script = (string) ($cmd[1] ?? ''); $bits = ['exit 127 = 命令不存在']; if ($python === '' || $python === 'python3') { $bits[] = '未找到可用 python(.env CORUNA_CHANNEL_BUILDER_NEW_PYTHON 为空且无 .venv)'; } elseif (! @is_file($python) && ! $this->pathIsLink($python)) { $bits[] = '解释器路径不存在: '.$python; } else { $target = $this->symlinkTarget($python); if ($target !== '') { $bits[] = 'venv python 软链指向 '.$target.'(目标机上可能没有这个 python)'; } } if ($script !== '' && ! is_file($script)) { $bits[] = '脚本不存在: '.$script; } if (! is_dir($cwd)) { $bits[] = '工作目录不存在: '.$cwd; } return implode(';', $bits); } private function hardeningHint(string $output, ?int $exit = null): string { $hay = strtolower($output); if (str_contains($hay, 'tips from bt security') || str_contains($hay, 'your request has been recorded') || $exit === 9 || $exit === 137) { return '堡塔防入侵拦截了 www 执行 python。软件商店 → 堡塔防入侵 → 看 www 拦截日志,把 venv python 与 /usr/bin/python3.10 加白后再建渠道'; } if (! str_contains($hay, 'py7zr') && ! str_contains($hay, 'permission denied') && ! str_contains($hay, 'cannot open shared object')) { return ''; } return '宝塔系统加固常去掉 /usr/bin/python3.10 与 venv 里 .so 的执行权限。' .'请把 /www/wwwroot/coruna-lab 加入加固排除,并 chmod 755 系统 python 与 venv 下 *.so'; } /** * @return array */ private function parseResultMarker(string $output, string $errorPrefix): array { $lines = preg_split("/\r\n|\n|\r/", $output) ?: []; for ($i = count($lines) - 1; $i >= 0; $i--) { $line = $lines[$i]; if (! str_starts_with($line, self::RESULT_MARKER)) { continue; } $json = substr($line, strlen(self::RESULT_MARKER)); $decoded = json_decode($json, true); if (! is_array($decoded)) { throw new RuntimeException("{$errorPrefix}: invalid builder JSON"); } return $decoded; } throw new RuntimeException("{$errorPrefix}: missing builder result marker"); } private function pythonBinary(string $builderType): string { $configKey = $builderType === self::BUILDER_NEW ? 'coruna.channel_builder_new.python' : 'coruna.channel_builder.python'; $configured = trim((string) config($configKey, '')); if ($configured !== '') { // Trust .env: do not is_file() — venv python is usually a symlink to // /usr/bin/python*, which fatals under panel open_basedir. return $configured; } $venv = $this->builderCwd($builderType).'/.venv/bin/python'; if ($this->venvPythonExists($venv)) { return $venv; } if ($builderType === self::BUILDER_NEW) { $fallback = base_path('channel-builder/.venv/bin/python'); if ($this->venvPythonExists($fallback)) { return $fallback; } } return 'python3'; } /** * Detect .venv/bin/python without following the symlink into /usr/bin * (open_basedir typically allows the project root only). */ private function venvPythonExists(string $path): bool { clearstatcache(true, $path); // is_link() checks the link inode inside the project; does not resolve target. if ($this->pathIsLink($path)) { return true; } return @is_file($path); } private function pathIsLink(string $path): bool { return function_exists('is_link') && @is_link($path); } private function symlinkTarget(string $path): string { if (! function_exists('readlink') || ! $this->pathIsLink($path)) { return ''; } $target = @\readlink($path); return is_string($target) ? $target : ''; } private function builderScript(string $name, string $builderType): string { $path = $this->builderCwd($builderType).'/tools/'.$name; if (! is_file($path)) { throw new RuntimeException("渠道构建脚本不存在: {$path}"); } return $path; } private function builderCwd(string $builderType): string { return $builderType === self::BUILDER_NEW ? base_path('channel-builder-new') : base_path('channel-builder'); } private function artifactRoot(): string { $root = trim((string) config('coruna.channel_builder.artifact_root', '')); if ($root === '') { $root = public_path(); } return $this->ensureDirectory($root, '产物目录'); } private function stateRoot(string $builderType): string { $key = $builderType === self::BUILDER_NEW ? 'coruna.channel_builder_new.state_root' : 'coruna.channel_builder.state_root'; $root = trim((string) config($key, '')); if ($root === '') { $root = $builderType === self::BUILDER_NEW ? storage_path('app/channel-builder-new') : storage_path('app/channel-builder'); } return $this->ensureDirectory($root, '构建状态目录'); } private function ensureDirectory(string $root, string $label): string { if (! is_dir($root) && ! mkdir($root, 0775, true) && ! is_dir($root)) { throw new RuntimeException("无法创建{$label}: {$root}"); } return $root; } private function normalizeBuilderType(string $builderType): string { $builderType = strtolower(trim($builderType)); if ($builderType === '') { return self::BUILDER_OLD; } if (! in_array($builderType, [self::BUILDER_OLD, self::BUILDER_NEW], true)) { throw new RuntimeException('无效的渠道类型(支持: old, new)'); } return $builderType; } private function normalizeChannelId(string $channelId): string { $channelId = strtolower(trim($channelId)); if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) { throw new RuntimeException('Invalid channel id'); } return $channelId; } private function normalizeSupportTemplate(string $supportTemplate): string { $supportTemplate = strtolower(trim($supportTemplate)); if ($supportTemplate === '') { return self::DEFAULT_SUPPORT_TEMPLATE; } if (! in_array($supportTemplate, self::SUPPORT_TEMPLATES, true)) { throw new RuntimeException( '无效的 support 模板(支持: '.implode(', ', self::SUPPORT_TEMPLATES).')' ); } return $supportTemplate; } /** * @return array{0: ?string, 1: ?string} */ private function normalizeOptionalSeeds( ?string $deploymentSeed, ?string $reportingSeed, ): array { $deploymentSeed = $deploymentSeed !== null ? trim($deploymentSeed) : null; $reportingSeed = $reportingSeed !== null ? trim($reportingSeed) : null; if (($deploymentSeed === null || $deploymentSeed === '') && ($reportingSeed === null || $reportingSeed === '')) { return [null, null]; } if ($deploymentSeed === null || $deploymentSeed === '' || $reportingSeed === null || $reportingSeed === '') { throw new RuntimeException('deployment_seed 与 reporting_seed 必须同时提供'); } foreach (['deployment_seed' => $deploymentSeed, 'reporting_seed' => $reportingSeed] as $name => $value) { if (! preg_match('/^[ -~]{1,32}$/', $value)) { throw new RuntimeException("无效的 {$name}(需 1–32 位 ASCII)"); } } if ($deploymentSeed !== $reportingSeed) { throw new RuntimeException('deployment_seed 与 reporting_seed 必须相同'); } return [$deploymentSeed, $reportingSeed]; } }