--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); }