From c24022b0153f82d15c5e70e152e62c204ad47005 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:48:21 +0200 Subject: [PATCH] chore(dev): add script to generate traffic analytics data --- scripts/generate-traffic.php | 292 +++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 scripts/generate-traffic.php diff --git a/scripts/generate-traffic.php b/scripts/generate-traffic.php new file mode 100644 index 0000000000..383831d7fa --- /dev/null +++ b/scripts/generate-traffic.php @@ -0,0 +1,292 @@ + --rate=600 --burst=5000 + * + * Options: --server= (default 0, localhost), --minutes=120, --rate=300 (requests per minute), + * --burst=3000, --visitors=2500, --interval=15 (seconds between batches) + */ + +use App\Actions\Server\StartSentinel; +use App\Enums\ProxyTypes; +use App\Helpers\SshMultiplexingHelper; +use App\Models\Application; +use App\Models\Server; +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Support\Facades\Process; + +require __DIR__.'/../vendor/autoload.php'; +$app = require __DIR__.'/../bootstrap/app.php'; +$app->make(Kernel::class)->bootstrap(); + +if (! isDev()) { + fwrite(STDERR, "This script writes fake traffic and runs only in a development instance.\n"); + exit(1); +} + +$options = getopt('', ['server:', 'minutes:', 'rate:', 'burst:', 'visitors:', 'interval:', 'help']); +if (isset($options['help'])) { + echo "Usage: php scripts/generate-traffic.php [--server=0] [--minutes=120] [--rate=300] [--burst=3000] [--visitors=2500] [--interval=15]\n"; + exit(0); +} + +$serverKey = (string) ($options['server'] ?? '0'); +$minutes = (float) ($options['minutes'] ?? 120); +$rate = (int) ($options['rate'] ?? 300); +$burst = (int) ($options['burst'] ?? 3000); +$visitorCount = (int) ($options['visitors'] ?? 2500); +$interval = max(1, (int) ($options['interval'] ?? 15)); + +$server = ctype_digit($serverKey) ? Server::find((int) $serverKey) : Server::where('uuid', $serverKey)->first(); +if (! $server) { + fwrite(STDERR, "Server {$serverKey} not found.\n"); + exit(1); +} + +$proxyType = $server->proxyType(); +if (! in_array($proxyType, [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) { + fwrite(STDERR, "Server {$server->name} uses proxy {$proxyType}. Sentinel reads only Traefik or Caddy access logs.\n"); + exit(1); +} +if (! $server->settings->is_traffic_analytics_enabled) { + fwrite(STDERR, "Warning: traffic analytics is not enabled on {$server->name}; Sentinel will not read the lines.\n"); +} + +/** @var array $apps uuid => weight */ +$apps = []; +/** @var array $hosts uuid => request host */ +$hosts = []; +$position = 0; +foreach (Application::query()->with('destination.server')->get() as $application) { + if ($application->destination?->server?->id !== $server->id) { + continue; + } + $apps[$application->uuid] = max(1, 30 - $position * 3); + $host = parse_url((string) str($application->fqdn)->before(',')->trim(), PHP_URL_HOST); + $hosts[$application->uuid] = $host ?: $application->uuid.'.example.com'; + $position++; +} +if ($apps === []) { + fwrite(STDERR, "Server {$server->name} has no applications. Traffic is attributed per application.\n"); + exit(1); +} + +$logPath = StartSentinel::trafficLogDirectory($server).'/access.log'; +$isCaddy = $proxyType === ProxyTypes::CADDY->value; + +echo "Server: {$server->name} ({$proxyType}), ".count($apps)." applications, log: {$logPath}\n"; + +/** Large residential ISP ranges; GeoIP resolves the real country and city. */ +$networks = [ + '73.0.0.0/8' => 22, // US Comcast + '98.0.0.0/10' => 6, // US + '91.0.0.0/10' => 12, // DE Telekom + '90.0.0.0/9' => 8, // FR Orange + '86.128.0.0/10' => 7, // GB BT + '93.32.0.0/11' => 6, // IT Fastweb + '83.32.0.0/11' => 5, // ES Telefonica + '77.160.0.0/12' => 4, // NL KPN + '84.0.0.0/14' => 4, // HU Magyar Telekom + '126.0.0.0/8' => 5, // JP SoftBank + '121.128.0.0/10' => 3, // KR KT + '49.36.0.0/14' => 6, // IN Jio + '177.0.0.0/8' => 5, // BR + '1.120.0.0/13' => 3, // AU Telstra + '70.24.0.0/13' => 3, // CA Bell + '78.56.0.0/13' => 2, // LT + '81.240.0.0/12' => 2, // BE Proximus + '83.0.0.0/11' => 2, // PL +]; + +$userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36' => 30, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15' => 12, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36' => 12, + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1' => 14, + 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36' => 10, + 'Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0' => 5, + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0' => 5, + 'Mozilla/5.0 (iPad; CPU OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1' => 2, + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' => 3, + 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' => 1, + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)' => 2, + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)' => 1, + 'curl/8.14.1' => 1, +]; + +$paths = [ + '/' => 30, '/about' => 5, '/pricing' => 6, '/blog' => 5, '/blog/launch-week' => 3, + '/blog/self-hosting-guide' => 2, '/docs' => 6, '/docs/getting-started' => 4, '/login' => 4, + '/dashboard' => 6, '/api/users' => 5, '/api/items' => 6, '/api/items/42' => 3, '/api/health' => 4, + '/assets/app.js' => 8, '/assets/app.css' => 7, '/favicon.ico' => 5, '/robots.txt' => 1, + '/wp-login.php' => 1, '/.env' => 1, +]; + +$referers = [ + '' => 45, 'https://www.google.com/' => 25, 'https://news.ycombinator.com/' => 6, 'https://x.com/' => 5, + 'https://github.com/' => 6, 'https://www.reddit.com/' => 5, 'https://duckduckgo.com/' => 4, 'https://t.co/' => 4, +]; + +$statuses = [200 => 780, 304 => 80, 301 => 30, 302 => 20, 404 => 50, 401 => 10, 403 => 10, 500 => 10, 502 => 6, 503 => 4]; +$protocols = ['HTTP/2.0' => 60, 'HTTP/1.1' => 30, 'HTTP/3.0' => 10]; + +/** + * @param array $weights value => weight + */ +function pickWeighted(array $weights): string|int +{ + $roll = mt_rand() / mt_getrandmax() * array_sum($weights); + foreach ($weights as $value => $weight) { + $roll -= $weight; + if ($roll <= 0) { + return $value; + } + } + + return array_key_last($weights); +} + +function randomIp(string $cidr): string +{ + [$network, $bits] = explode('/', $cidr); + $size = 2 ** (32 - (int) $bits); + + return long2ip(ip2long($network) + random_int(1, $size - 2)); +} + +function logNormal(float $mu, float $sigma): float +{ + $u1 = max(mt_rand() / mt_getrandmax(), 1e-12); + $u2 = mt_rand() / mt_getrandmax(); + + return exp($mu + $sigma * sqrt(-2 * log($u1)) * cos(2 * M_PI * $u2)); +} + +// A fixed pool of visitors (IP + browser + favorite app), with a few heavy users. +$visitors = []; +$visitorWeights = []; +for ($i = 0; $i < $visitorCount; $i++) { + $visitors[] = [ + 'ip' => randomIp(pickWeighted($networks)), + 'ua' => pickWeighted($userAgents), + 'app' => pickWeighted($apps), + ]; + $visitorWeights[$i] = (1 - mt_rand() / (mt_getrandmax() + 1)) ** (-1 / 1.3); +} + +$makeLine = function (array $visitor) use ($apps, $hosts, $paths, $referers, $statuses, $protocols, $isCaddy): string { + $path = pickWeighted($paths); + $status = in_array($path, ['/wp-login.php', '/.env'], true) ? 404 : pickWeighted($statuses); + $app = random_int(1, 100) <= 85 ? $visitor['app'] : pickWeighted($apps); + $isApi = str_starts_with($path, '/api'); + $bytesOut = in_array($status, [301, 302, 304], true) ? 0 : ($isApi ? random_int(80, 900) : (int) logNormal(9, 1.2)); + $bytesIn = random_int(0, 600); + $durationMs = logNormal(log($isApi ? 60 : 35), 0.9) * ($status >= 500 ? 8 : 1); + $method = $isApi && random_int(1, 100) <= 20 ? 'POST' : 'GET'; + $protocol = pickWeighted($protocols); + $referer = pickWeighted($referers); + + if ($isCaddy) { + $headers = ['User-Agent' => [$visitor['ua']]]; + if ($referer !== '') { + $headers['Referer'] = [$referer]; + } + + return json_encode([ + 'ts' => microtime(true), + 'status' => $status, + 'duration' => $durationMs / 1000, + 'size' => $bytesOut, + 'bytes_read' => $bytesIn, + 'coolify_app_id' => $app, + 'request' => [ + 'method' => $method, + 'uri' => $path, + 'host' => $hosts[$app], + 'proto' => $protocol, + 'remote_ip' => $visitor['ip'], + 'client_ip' => $visitor['ip'], + 'headers' => $headers, + 'tls' => ['version' => 772], + ], + ], JSON_UNESCAPED_SLASHES); + } + + return json_encode([ + 'StartUTC' => now()->utc()->format('Y-m-d\TH:i:s.u\Z'), + 'RequestMethod' => $method, + 'RequestPath' => $path, + 'RequestHost' => $hosts[$app], + 'DownstreamStatus' => $status, + 'DownstreamContentSize' => $bytesOut, + 'RequestContentSize' => $bytesIn, + 'Duration' => (int) ($durationMs * 1_000_000), + 'RequestProtocol' => $protocol, + 'RequestScheme' => 'https', + 'TLSVersion' => '1.3', + 'RouterName' => "https-0-{$app}@docker", + 'ClientHost' => $visitor['ip'], + 'request_User-Agent' => $visitor['ua'], + 'request_Referer' => $referer, + ], JSON_UNESCAPED_SLASHES); +}; + +$batch = function (int $count) use ($visitors, $visitorWeights, $makeLine): string { + $lines = []; + for ($i = 0; $i < $count; $i++) { + $lines[] = $makeLine($visitors[pickWeighted($visitorWeights)]); + } + + return implode("\n", $lines)."\n"; +}; + +$remoteCommand = $server->isNonRoot() + ? 'sudo tee -a '.escapeshellarg($logPath).' > /dev/null' + : 'cat >> '.escapeshellarg($logPath); + +$send = function (string $payload) use ($server, $remoteCommand): void { + Process::input($payload) + ->timeout(120) + ->run(SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand)) + ->throw(); +}; + +if ($burst > 0) { + $send($batch($burst)); + echo "Burst: {$burst} requests\n"; +} + +$start = time(); +$end = $start + (int) ($minutes * 60); +while (time() < $end) { + // A slow wave plus noise and an occasional spike, so the requests chart is not flat. + $wave = 1 + 0.5 * sin((time() - $start) / 1800 * M_PI); + $spike = random_int(1, 100) <= 3 ? 4 : 1; + $count = max(1, (int) round($rate * $interval / 60 * $wave * $spike * (0.85 + random_int(0, 30) / 100))); + + try { + $send($batch($count)); + echo now()->format('H:i:s')." sent {$count}\n"; + } catch (Throwable $e) { + fwrite(STDERR, 'Send failed: '.$e->getMessage()."\n"); + } + + sleep($interval); +}