create(); $this->team = $user->teams()->first(); }); it('produces no traffic env when disabled', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $server->settings->is_traffic_analytics_enabled = false; $server->settings->save(); expect(StartSentinel::sentinelTrafficEnvironment($server->fresh()))->toBe([]); }); it('produces traffic + geoip env when enabled', function () { $server = trafficProxyServer($this); $server->settings->is_traffic_analytics_enabled = true; $server->settings->geoip_maxmind_license_key = 'lic'; $server->settings->save(); $env = StartSentinel::sentinelTrafficEnvironment($server->fresh()); expect($env['TRAFFIC_ENABLED'])->toBe('true'); expect($env['TRAFFIC_PROXY_TYPE'])->toBe('auto'); expect($env['TRAFFIC_TOPN'])->toBe('50'); expect($env['TRAFFIC_SAMPLE_THRESHOLD'])->toBe('0'); expect($env['TRAFFIC_RETENTION_1H_DAYS'])->toBe('30'); expect($env['TRAFFIC_RETENTION_1D_DAYS'])->toBe('395'); expect($env['GEOIP_ENABLED'])->toBe('true'); expect($env['GEOIP_REFRESH_DAYS'])->toBe('30'); expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('lic'); expect($env['TRAFFIC_IP_MODE'])->toBe('full'); expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH'); }); it('passes the saved client IP mode as sentinel env', function (string $mode) { $server = trafficProxyServer($this); $server->settings->is_traffic_analytics_enabled = true; $server->settings->traffic_ip_mode = $mode; $server->settings->save(); expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_IP_MODE'])->toBe($mode); })->with(['anonymized', 'off']); it('uses the dev proxy volume for traffic logs only on the testing-host server locally', function (string $ip, string $directory) { config()->set('app.env', 'local'); $server = trafficProxyServer($this); $server->update(['ip' => $ip]); $server->settings->is_traffic_analytics_enabled = true; $server->settings->save(); expect(StartSentinel::trafficLogDirectory($server->fresh())) ->toBe($directory); expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_ACCESS_LOG_PATH']) ->toBe("{$directory}/access.log"); })->with([ 'dev KVM server' => ['10.221.1.10', '/data/coolify/proxy'], 'dev testing-host server' => [Server::DEV_TESTING_HOST_IP, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'], ]); it('passes custom traffic settings as sentinel env', function () { $server = trafficProxyServer($this); $server->settings->is_traffic_analytics_enabled = true; $server->settings->traffic_topn = 100; $server->settings->traffic_sample_threshold = 500; $server->settings->traffic_retention_1h_days = 14; $server->settings->traffic_retention_1d_days = 180; $server->settings->is_geoip_enabled = false; $server->settings->geoip_refresh_days = 7; // A key may still be stored while GeoIP is off; it must not reach Sentinel. $server->settings->geoip_maxmind_license_key = 'secret-maxmind-key'; $server->settings->save(); $env = StartSentinel::sentinelTrafficEnvironment($server->fresh()); expect($env['TRAFFIC_TOPN'])->toBe('100'); expect($env['TRAFFIC_SAMPLE_THRESHOLD'])->toBe('500'); expect($env['TRAFFIC_RETENTION_1H_DAYS'])->toBe('14'); expect($env['TRAFFIC_RETENTION_1D_DAYS'])->toBe('180'); expect($env['GEOIP_ENABLED'])->toBe('false'); expect($env['GEOIP_REFRESH_DAYS'])->toBe('7'); expect($env)->not->toHaveKey('GEOIP_MAXMIND_LICENSE_KEY'); }); it('injects the maxmind license key only when geoip is enabled', function () { $server = trafficProxyServer($this); $server->settings->is_traffic_analytics_enabled = true; $server->settings->is_geoip_enabled = true; $server->settings->geoip_maxmind_license_key = 'secret-maxmind-key'; $server->settings->save(); $env = StartSentinel::sentinelTrafficEnvironment($server->fresh()); expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('secret-maxmind-key'); }); function trafficProxyServer(object $test): Server { $server = Server::factory()->create(['team_id' => $test->team->id]); $server->proxy->set('type', 'TRAEFIK'); $server->save(); return $server; } function sentinelTrafficServer(object $test, ?string $proxyType, bool $analyticsEnabled): Server { $server = Server::factory()->create([ 'team_id' => $test->team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $test->team->id])->id, ]); $server->proxy->set('type', $proxyType); $server->save(); $server->settings->is_traffic_analytics_enabled = $analyticsEnabled; $server->settings->sentinel_custom_url = 'https://coolify.example.com'; $server->settings->save(); return $server->fresh(); } /** * Runs StartSentinel against a faked SSH process and returns the remote script it sent. */ function runStartSentinelAndCaptureScript(Server $server): string { if (! InstanceSettings::query()->whereKey(0)->exists()) { InstanceSettings::forceCreate(['id' => 0]); } config(['constants.ssh.mux_enabled' => false]); Event::fake([SentinelRestarted::class]); $scripts = []; Process::fake(function ($process) use (&$scripts) { $scripts[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); StartSentinel::run($server, latestVersion: '1.0.1'); return collect($scripts)->first(fn (string $script): bool => str_contains($script, 'docker run -d')); } it('creates the access log before starting sentinel with traffic analytics', function (string $proxyType, string $directory) { $server = sentinelTrafficServer($this, $proxyType, analyticsEnabled: true); expect(StartSentinel::trafficLogPreparationCommands($server))->toBe([ 'mkdir -p '.escapeshellarg($directory), 'touch '.escapeshellarg($directory.'/access.log'), ]); $script = runStartSentinelAndCaptureScript($server); $touch = 'touch '.escapeshellarg($directory.'/access.log'); expect($script)->toContain('mkdir -p '.escapeshellarg($directory)) ->toContain($touch) ->not->toContain('> '.$directory.'/access.log') ->and(strpos($script, 'mkdir -p '.escapeshellarg($directory)))->toBeLessThan(strpos($script, $touch)) ->and(strpos($script, $touch))->toBeLessThan(strpos($script, 'docker run -d')); })->with([ 'traefik' => ['TRAEFIK', '/data/coolify/proxy'], 'caddy' => ['CADDY', '/data/coolify/proxy/caddy'], ]); it('starts sentinel with a restart policy so Docker restarts it after a crash or reboot', function () { $script = runStartSentinelAndCaptureScript(sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: false)); expect($script)->toContain('--name coolify-sentinel --restart unless-stopped '); }); it('starts sentinel in the host user namespace so --pid host works on a userns-remap daemon', function () { $script = runStartSentinelAndCaptureScript(sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: false)); expect($script)->toContain('--userns host --pid host '); }); it('does not touch the access log when traffic analytics cannot run', function (?string $proxyType, bool $analyticsEnabled) { $server = sentinelTrafficServer($this, $proxyType, $analyticsEnabled); expect(StartSentinel::trafficLogPreparationCommands($server))->toBe([]); $script = runStartSentinelAndCaptureScript($server); expect($script)->toContain('docker run -d') ->not->toContain('touch ') ->not->toContain('/data/coolify/proxy:/data/coolify/proxy') ->not->toContain('TRAFFIC_ENABLED'); })->with([ 'analytics disabled' => ['TRAEFIK', false], 'proxy none' => ['NONE', true], ]); it('does not touch the access log on swarm or build servers', function (string $setting, mixed $value) { $server = sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: true); $server->settings->{$setting} = $value; $server->settings->save(); expect(StartSentinel::trafficLogPreparationCommands($server->fresh()))->toBe([]); })->with([ 'swarm' => ['is_swarm_manager', true], 'build' => ['server_role', ServerRole::BUILD], ]); it('keeps the access log commands valid for non-root servers', function () { $server = sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: true); $server->user = 'ubuntu'; $commands = parseCommandsByLineForSudo(collect(StartSentinel::trafficLogPreparationCommands($server)), $server); expect($commands)->toBe([ "sudo mkdir -p '/data/coolify/proxy'", "sudo touch '/data/coolify/proxy/access.log'", ]); $script = tempnam(sys_get_temp_dir(), 'sentinel-traffic'); file_put_contents($script, implode("\n", $commands)."\n"); $syntax = SymfonyProcess::fromShellCommandline('bash -n '.escapeshellarg($script)); $syntax->run(); unlink($script); expect($syntax->isSuccessful())->toBeTrue($syntax->getErrorOutput()); }); it('mounts the configured dev data volume for traffic logs and Sentinel data on the testing-host server', function () { config()->set('app.env', 'local'); config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data'); $server = sentinelTrafficServer($this, 'CADDY', analyticsEnabled: true); $server->update(['ip' => Server::DEV_TESTING_HOST_IP]); $server = $server->fresh(); $directory = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy'; $script = runStartSentinelAndCaptureScript($server); expect(StartSentinel::trafficLogDirectory($server))->toBe($directory) ->and($script)->toContain(escapeshellarg("{$directory}:{$directory}:ro")) ->toContain(escapeshellarg("TRAFFIC_ACCESS_LOG_PATH={$directory}/access.log")) ->toContain('-v /var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/sentinel:/app/db') ->not->toContain('coolify_dev_coolify_data'); });