diff --git a/.ai/lessons.md b/.ai/lessons.md index 3d2c6a1e68..84d6bc1992 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -58,6 +58,7 @@ ## Trace infrastructure changes end to end - For container image changes, inspect Compose services and every relevant Dockerfile build stage. - Pin a stable release tag instead of using a floating `latest` tag. +- Never give a test build an official image tag (for example `coollabsio/sentinel:`) in the host Docker. Dev instances that use `testing-host` share the host Docker socket and run that tag without a pull. Load such a tag only inside a dev VM, and give the build the same version string, or Coolify's Sentinel check restarts it every minute. - A successful image pull does not prove that the complete application build no longer uses the old image. - Do not use `docker compose up --wait` for a stack with one-shot services; wait for the required long-running service's health instead. diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index f58c995c96..3dcc2650ac 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Enums\TrafficIpMode; use App\Events\SentinelRestarted; use App\Models\Server; use Illuminate\Contracts\Cache\LockTimeoutException; @@ -58,6 +59,7 @@ class StartSentinel 'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395), 'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false', 'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30), + 'TRAFFIC_IP_MODE' => TrafficIpMode::forServer($server)->value, ]; $license = data_get($settings, 'geoip_maxmind_license_key'); if ($settings->is_geoip_enabled && filled($license)) { diff --git a/app/Enums/TrafficIpMode.php b/app/Enums/TrafficIpMode.php new file mode 100644 index 0000000000..6f2b09d89a --- /dev/null +++ b/app/Enums/TrafficIpMode.php @@ -0,0 +1,72 @@ +settings?->traffic_ip_mode ?? self::Full; + } + + /** + * Effective mode for a view that merges several servers: Off only when every server is off, + * Anonymized when every server that records IPs anonymizes them, otherwise Full. + * + * @param iterable $servers + */ + public static function forServers(iterable $servers): self + { + $modes = collect($servers)->map(fn (Server $server): self => self::forServer($server)); + + if ($modes->isEmpty()) { + return self::Full; + } + + $recording = $modes->reject(fn (self $mode): bool => $mode === self::Off); + + if ($recording->isEmpty()) { + return self::Off; + } + + return $recording->every(fn (self $mode): bool => $mode === self::Anonymized) + ? self::Anonymized + : self::Full; + } + + /** + * Drop the `ip` dimension when Sentinel does not record it. + * + * @param array $dimensions + * @return array + */ + public function breakdownDimensions(array $dimensions): array + { + if ($this !== self::Off) { + return $dimensions; + } + + return array_values(array_filter($dimensions, fn (string $dimension): bool => $dimension !== 'ip')); + } + + public function breakdownLabel(): string + { + return $this === self::Anonymized ? 'Top networks' : 'Top IPs'; + } + + public function breakdownHelper(): string + { + return $this === self::Anonymized + ? 'Busiest client networks. IPs are anonymized to /24 (IPv4) and /48 (IPv6).' + : 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).'; + } +} diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index f3e15b5c14..04076c7ce7 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Enums\TrafficIpMode; use App\Http\Controllers\Controller; use App\Models\Server; use App\Models\ServerSetting; @@ -26,6 +27,7 @@ class ServerSentinelController extends Controller 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', + 'traffic_ip_mode', ]; private function findServerForTeam(int $teamId, string $uuid): ?Server @@ -55,6 +57,7 @@ class ServerSentinelController extends Controller 'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days, 'is_geoip_enabled' => (bool) $settings->is_geoip_enabled, 'geoip_refresh_days' => (int) $settings->geoip_refresh_days, + 'traffic_ip_mode' => TrafficIpMode::forServer($server)->value, ]; if ($this->canReadSensitive()) { @@ -97,6 +100,7 @@ class ServerSentinelController extends Controller new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'), new OA\Property(property: 'is_geoip_enabled', type: 'boolean'), new OA\Property(property: 'geoip_refresh_days', type: 'integer'), + new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs for the Top IPs breakdown.'), new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'), ], type: 'object', @@ -152,6 +156,7 @@ class ServerSentinelController extends Controller new OA\Property(property: 'is_geoip_enabled', type: 'boolean'), new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1), new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true), + new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.'), ], type: 'object', ), @@ -198,6 +203,7 @@ class ServerSentinelController extends Controller 'is_geoip_enabled' => 'boolean', 'geoip_refresh_days' => 'integer|min:1', 'geoip_maxmind_license_key' => 'nullable|string|max:255', + 'traffic_ip_mode' => 'string|in:full,anonymized,off', ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index c55eb6c8f8..5fa1801a5e 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -816,10 +816,10 @@ class ServersController extends Controller ], 422); } - if ($serverRole !== null && $serverRole !== ServerRole::BUILD && $server->hasEnabledGithubRunners()) { + if ($serverRole === ServerRole::DEPLOYMENT && $server->hasEnabledGithubRunners()) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => ['server_role' => ['Disable the GitHub runners before you change the role of this server.']], + 'errors' => ['server_role' => ['Disable the GitHub runners before you set this server to deployments only.']], ], 422); } diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php index f170c34c3b..c5b1148448 100644 --- a/app/Jobs/ProvisionGithubRunnerJob.php +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -19,7 +19,7 @@ use Illuminate\Support\Sleep; use RuntimeException; /** - * Starts one ephemeral runner for a queued execution on the least busy matching build server. + * Starts one ephemeral runner for a queued execution on the least busy matching server. * When no server has free capacity, the execution stays queued and is retried by * CleanupGithubRunnerJob or ReconcileGithubRunnersJob. A runner that fails to start is removed and * started again, possibly on another server, up to MAX_ATTEMPTS times. @@ -191,7 +191,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue ->filter(fn (GithubRunnerConfig $config) => $config->server !== null && $config->matchesLabels($execution->labels ?? []) && ($config->allow_pull_requests || ! $execution->is_pull_request) - && $config->server->isBuildServer() + && $config->server->canBuildApplications() && ! $config->server->isLocalhost() && $config->server->isFunctional()) ->map(fn (GithubRunnerConfig $config) => ['config' => $config, 'occupied' => $config->occupiedRunnerCount()]) diff --git a/app/Jobs/ReconcileGithubRunnersJob.php b/app/Jobs/ReconcileGithubRunnersJob.php index 6ec70a26b2..f3adb513c4 100644 --- a/app/Jobs/ReconcileGithubRunnersJob.php +++ b/app/Jobs/ReconcileGithubRunnersJob.php @@ -17,7 +17,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; /** - * Compares runner executions with the runner containers on each build server. It cleans up + * Compares runner executions with the runner containers on each runner server. It cleans up * stopped and orphaned runners, applies the wait, idle, and job timeouts, and restarts * provisioning for queued executions whose dispatch was lost. */ @@ -161,7 +161,7 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue foreach ($executions as $execution) { if (($execution->queued_at ?? $execution->created_at)->lt(now()->subMinutes($timeout))) { - $execution->finish(GithubRunnerStatus::TimedOut, "No build server had free capacity within {$timeout} minutes."); + $execution->finish(GithubRunnerStatus::TimedOut, "No runner server had free capacity within {$timeout} minutes."); } } diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 00b0e0d23f..e4400f58ec 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Enums\TrafficIpMode; use App\Livewire\Concerns\BuildsTrafficChartPayload; use App\Models\Application; use App\Models\Server; @@ -79,6 +80,10 @@ class Analytics extends Component public ?string $attribution = null; + /** Effective TrafficIpMode of the queried servers; decides how the IP breakdown renders. */ + #[Locked] + public string $ipMode = 'full'; + /** * Per-bucket status-class time series for the stacked area chart, summed across * target servers and sorted by bucket. @@ -342,8 +347,11 @@ class Analytics extends Component $resource = $this->selectedResource(); $servers = $this->targetServers(); $domainForKey = fn (string $key): ?string => $this->appMeta($key)['domain']; + $ipMode = TrafficIpMode::forServers($servers); + $this->ipMode = $ipMode->value; + $dimensions = $ipMode->breakdownDimensions($this->breakdownDimensions); - $aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions); + $aggregator = new TrafficAnalyticsAggregator($dimensions); $appRows = []; $hostTotals = []; @@ -362,7 +370,7 @@ class Analytics extends Component // Warm every server-wide endpoint, including the leaderboard's per-key // overviews, in one dashboard fetch; the per-call methods below then read // from cache. - $leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); + $leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $dimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) { Log::warning('Traffic analytics leaderboard truncated', [ diff --git a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php index cbc1caac1a..d936333a92 100644 --- a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php +++ b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Enums\TrafficIpMode; use App\Livewire\Concerns\BuildsTrafficChartPayload; use App\Models\Application; use App\Models\Service; @@ -52,6 +53,10 @@ abstract class ResourceTrafficAnalytics extends Component public ?string $attribution = null; + /** TrafficIpMode of the resource's server; decides how the IP breakdown renders. */ + #[Locked] + public string $ipMode = 'full'; + /** * Per-bucket status-class time series for the stacked area chart. * @@ -131,11 +136,14 @@ abstract class ResourceTrafficAnalytics extends Component $resource = $this->trafficResource(); [$from, $to] = SentinelTrafficClient::rangeWindow($this->range); - $client = app(SentinelTrafficClient::class, ['server' => $resource->server()]); + $server = $resource->server(); + $client = app(SentinelTrafficClient::class, ['server' => $server]); + $ipMode = $server ? TrafficIpMode::forServer($server) : TrafficIpMode::Full; + $this->ipMode = $ipMode->value; // Sentinel's resource scope merges every key exactly in one docker exec. A Sentinel // before 1.0.2 falls back to all keys of the resource, merged here (approximate). - $aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions); + $aggregator = new TrafficAnalyticsAggregator($ipMode->breakdownDimensions($this->breakdownDimensions)); $aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey)); $result = $aggregator->overview(); diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php index 3613339f28..598a8485fc 100644 --- a/app/Livewire/Server/GithubRunners.php +++ b/app/Livewire/Server/GithubRunners.php @@ -183,8 +183,8 @@ class GithubRunners extends Component } try { - if (! $this->server->isBuildServer()) { - throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.'); + if (! $this->server->canBuildApplications()) { + throw new \RuntimeException('GitHub Actions runners need a server with the Builds only or Deployments and builds role.'); } if ($this->config?->is_enabled ?? true) { @@ -242,8 +242,8 @@ class GithubRunners extends Component }); $this->dispatch('success', 'GitHub runners disabled. Running jobs continue until they finish.'); } else { - if (! $this->server->isBuildServer()) { - throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.'); + if (! $this->server->canBuildApplications()) { + throw new \RuntimeException('GitHub Actions runners need a server with the Builds only or Deployments and builds role.'); } $this->authorize('update', $config->githubApp); $this->prepareRunnerGroup($config->githubApp); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cea2c3f465..8357e89257 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -476,9 +476,9 @@ class Show extends Component $newRole = ServerRole::from($this->serverRole); $currentRole = $this->server->settings()->firstOrFail()->effectiveServerRole(); - if ($newRole !== ServerRole::BUILD && $this->server->hasEnabledGithubRunners()) { + if ($newRole === ServerRole::DEPLOYMENT && $this->server->hasEnabledGithubRunners()) { $this->serverRole = $currentRole->value; - $this->dispatch('error', 'Disable the GitHub runners before you change the role of this server.'); + $this->dispatch('error', 'Disable the GitHub runners before you set this server to deployments only.'); return; } diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index 6c782cff6e..bbfc5d8204 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -4,6 +4,7 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureTrafficAnalytics; use App\Enums\ProxyTypes; +use App\Enums\TrafficIpMode; use App\Livewire\Analytics; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -39,6 +40,9 @@ class TrafficAnalyticsSettings extends Component #[Validate(['nullable', 'string', 'max:255'])] public ?string $geoipMaxmindLicenseKey = null; + #[Validate(['required', 'string', 'in:full,anonymized,off'])] + public string $trafficIpMode = 'full'; + public function mount(): void { $this->authorize('update', $this->server); @@ -56,6 +60,7 @@ class TrafficAnalyticsSettings extends Component $this->server->settings->is_geoip_enabled = $this->isGeoipEnabled; $this->server->settings->geoip_refresh_days = $this->geoipRefreshDays; $this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey; + $this->server->settings->traffic_ip_mode = TrafficIpMode::from($this->trafficIpMode); $this->server->settings->save(); return; @@ -69,6 +74,7 @@ class TrafficAnalyticsSettings extends Component $this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled; $this->geoipRefreshDays = $this->server->settings->geoip_refresh_days; $this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key; + $this->trafficIpMode = TrafficIpMode::forServer($this->server)->value; } public function toggleTrafficAnalytics(): void @@ -99,7 +105,7 @@ class TrafficAnalyticsSettings extends Component $this->authorize('update', $this->server); $this->syncData(true); auditLog('ui.server.traffic_analytics.updated', $this->auditContext([ - 'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key'], + 'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', 'traffic_ip_mode'], ])); $this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index fab0d406ec..c7b28fb4ef 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ServerRole; +use App\Enums\TrafficIpMode; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; @@ -36,6 +37,7 @@ use OpenApi\Attributes as OA; 'traffic_retention_1d_days' => ['type' => 'integer'], 'is_geoip_enabled' => ['type' => 'boolean'], 'geoip_refresh_days' => ['type' => 'integer'], + 'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']], 'is_reachable' => ['type' => 'boolean'], 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], @@ -131,6 +133,7 @@ class ServerSetting extends Model 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', + 'traffic_ip_mode', 'docker_version', 'docker_version_checked_at', 'compose_version', @@ -167,6 +170,7 @@ class ServerSetting extends Model 'is_geoip_enabled' => 'boolean', 'geoip_refresh_days' => 'integer', 'geoip_maxmind_license_key' => 'encrypted', + 'traffic_ip_mode' => TrafficIpMode::class, 'docker_version_checked_at' => 'datetime', 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', @@ -218,7 +222,8 @@ class ServerSetting extends Model $settings->wasChanged('traffic_retention_1d_days') || $settings->wasChanged('is_geoip_enabled') || $settings->wasChanged('geoip_refresh_days') || - $settings->wasChanged('geoip_maxmind_license_key') + $settings->wasChanged('geoip_maxmind_license_key') || + $settings->wasChanged('traffic_ip_mode') ) { // Only recreate Sentinel when it is already enabled. Otherwise a change to a // traffic/geoip tuning knob would turn Sentinel on as a side effect, because diff --git a/app/Services/GithubRunner/GithubRunnerContainer.php b/app/Services/GithubRunner/GithubRunnerContainer.php index c970490be7..09839249a5 100644 --- a/app/Services/GithubRunner/GithubRunnerContainer.php +++ b/app/Services/GithubRunner/GithubRunnerContainer.php @@ -162,7 +162,7 @@ class GithubRunnerContainer #!/bin/bash case "\${GITHUB_EVENT_NAME:-}" in {$events}|"") - echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the build server to run them." + echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the server to run them." exit 1 ;; esac diff --git a/database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php b/database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php new file mode 100644 index 0000000000..85d7c69e12 --- /dev/null +++ b/database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php @@ -0,0 +1,28 @@ +string('traffic_ip_mode')->default('full'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('traffic_ip_mode'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 9b681adc06..572091bba7 100644 --- a/openapi.json +++ b/openapi.json @@ -1021,7 +1021,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -1642,7 +1642,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -2263,7 +2263,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -2779,7 +2779,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -3275,7 +3275,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -9337,6 +9337,9 @@ }, "409": { "description": "Another start, restart or import of this database is already in progress." + }, + "422": { + "description": "The database cannot start yet, for example because a CA certificate is missing." } }, "security": [ @@ -9455,6 +9458,9 @@ }, "409": { "description": "Another start, restart or import of this database is already in progress." + }, + "422": { + "description": "The database cannot start yet, for example because a CA certificate is missing." } }, "security": [ @@ -17221,6 +17227,15 @@ "geoip_refresh_days": { "type": "integer" }, + "traffic_ip_mode": { + "description": "How Sentinel stores client IPs for the Top IPs breakdown.", + "type": "string", + "enum": [ + "full", + "anonymized", + "off" + ] + }, "geoip_maxmind_license_key": { "description": "Only present with read:sensitive.", "type": "string" @@ -17329,6 +17344,15 @@ "string", "null" ] + }, + "traffic_ip_mode": { + "description": "How Sentinel stores client IPs: full, anonymized (IPv4 \/24, IPv6 \/48), or off.", + "type": "string", + "enum": [ + "full", + "anonymized", + "off" + ] } }, "type": "object" @@ -23400,6 +23424,13 @@ "default": 36000, "maximum": 36000, "minimum": 60 + }, + "missing_backup_notification_days": { + "description": "Alert after this many days without an execution; 0 disables alerts", + "type": "integer", + "default": 0, + "maximum": 365, + "minimum": 0 } }, "type": "object", @@ -23422,7 +23453,8 @@ "retention_amount_s3", "retention_days_s3", "retention_max_storage_s3", - "timeout" + "timeout", + "missing_backup_notification_days" ], "properties": { "uuid": { @@ -23484,6 +23516,9 @@ }, "timeout": { "type": "integer" + }, + "missing_backup_notification_days": { + "type": "integer" } }, "type": "object" @@ -24546,6 +24581,14 @@ "geoip_refresh_days": { "type": "integer" }, + "traffic_ip_mode": { + "type": "string", + "enum": [ + "full", + "anonymized", + "off" + ] + }, "is_reachable": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index a31fc5b1df..0884149b38 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -678,7 +678,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -1087,7 +1087,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -1496,7 +1496,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -1846,7 +1846,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -2182,7 +2182,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -6119,6 +6119,8 @@ paths: $ref: '#/components/responses/404' '409': description: 'Another start, restart or import of this database is already in progress.' + '422': + description: 'The database cannot start yet, for example because a CA certificate is missing.' security: - bearerAuth: [] @@ -6194,6 +6196,8 @@ paths: $ref: '#/components/responses/404' '409': description: 'Another start, restart or import of this database is already in progress.' + '422': + description: 'The database cannot start yet, for example because a CA certificate is missing.' security: - bearerAuth: [] @@ -10892,6 +10896,7 @@ paths: traffic_retention_1d_days: { type: integer } is_geoip_enabled: { type: boolean } geoip_refresh_days: { type: integer } + traffic_ip_mode: { description: 'How Sentinel stores client IPs for the Top IPs breakdown.', type: string, enum: [full, anonymized, 'off'] } geoip_maxmind_license_key: { description: 'Only present with read:sensitive.', type: string } type: object '401': @@ -10962,6 +10967,10 @@ paths: minimum: 1 geoip_maxmind_license_key: type: [string, 'null'] + traffic_ip_mode: + description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.' + type: string + enum: [full, anonymized, 'off'] type: object responses: '200': @@ -14875,6 +14884,12 @@ components: default: 36000 maximum: 36000 minimum: 60 + missing_backup_notification_days: + description: 'Alert after this many days without an execution; 0 disables alerts' + type: integer + default: 0 + maximum: 365 + minimum: 0 type: object additionalProperties: false VolumeBackupScheduleResponse: @@ -14895,6 +14910,7 @@ components: - retention_days_s3 - retention_max_storage_s3 - timeout + - missing_backup_notification_days properties: uuid: type: string @@ -14937,6 +14953,8 @@ components: format: float timeout: type: integer + missing_backup_notification_days: + type: integer type: object Application: description: 'Application model' @@ -15717,6 +15735,12 @@ components: type: boolean geoip_refresh_days: type: integer + traffic_ip_mode: + type: string + enum: + - full + - anonymized + - 'off' is_reachable: type: boolean is_sentinel_enabled: diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index cfcb6e1594..c326ac29d5 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -370,12 +370,15 @@ $appListboxOptions = array_merge( 'rows' => data_get($breakdowns, 'agent', []), 'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.', ]) - @include('livewire.traffic._breakdown-section', [ - 'dimension' => 'ip', - 'label' => 'Top IPs', - 'rows' => data_get($breakdowns, 'ip', []), - 'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).', - ]) + @php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full) + @if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off) + @include('livewire.traffic._breakdown-section', [ + 'dimension' => 'ip', + 'label' => $ipModeEnum->breakdownLabel(), + 'rows' => data_get($breakdowns, 'ip', []), + 'helper' => $ipModeEnum->breakdownHelper(), + ]) + @endif {{-- User agents (full width — raw UA strings are long). --}} diff --git a/resources/views/livewire/project/application/analytics.blade.php b/resources/views/livewire/project/application/analytics.blade.php index 692d3fda82..b2458f4cf8 100644 --- a/resources/views/livewire/project/application/analytics.blade.php +++ b/resources/views/livewire/project/application/analytics.blade.php @@ -214,12 +214,15 @@ $approxBadge = fn (string $tooltip) => '
- @if (!$server->isBuildServer()) + @if (! $server->canBuildApplications()) @@ -22,13 +22,13 @@ @elseif ($this->githubApps->isEmpty()) Runners are registered at organization level. Add a GitHub App that belongs to an organization - under Sources, and select "Run workflow jobs on build servers" when you register it. + under Sources, and select "Run workflow jobs on your servers" when you register it. @elseif (! $this->config?->is_enabled) - - Enable runners - + @if ($server->isBuildServer()) + + Enable runners + + @else + + + + Enable runners + + + + @endif @@ -63,6 +77,13 @@ @endcan + @if (! $server->isBuildServer()) + + Workflow jobs use the same CPU, memory, and disk as the resources on this server. A heavy + job can slow down or stop them. Use a server with the Builds only role for runners when + you can. + + @endif
In the privileged Docker mode, a malicious workflow can take control of this server. @@ -99,12 +120,14 @@ - + @if ($server->isBuildServer()) + + @endif diff --git a/resources/views/livewire/server/new/by-ip.blade.php b/resources/views/livewire/server/new/by-ip.blade.php index 772ede2ac5..628f6656b3 100644 --- a/resources/views/livewire/server/new/by-ip.blade.php +++ b/resources/views/livewire/server/new/by-ip.blade.php @@ -84,11 +84,11 @@
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index ff159bdf1a..565fda89a3 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -238,11 +238,11 @@
@endif diff --git a/resources/views/livewire/server/traffic-analytics-settings.blade.php b/resources/views/livewire/server/traffic-analytics-settings.blade.php index 93ae23a423..09ee724eeb 100644 --- a/resources/views/livewire/server/traffic-analytics-settings.blade.php +++ b/resources/views/livewire/server/traffic-analytics-settings.blade.php @@ -2,7 +2,7 @@
@if ($isTrafficAnalyticsEnabled) + targets="trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey,trafficIpMode" /> @endif false, 'label' => 'Disabled'], ]" helper="Country enrichment from visitor IPs. Disable to skip GeoIP lookups." /> + diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 38cef644d9..383d54bc6a 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -314,7 +314,7 @@ :disabled="blank($github_app->organization)" :options="[ ['value' => false, 'label' => 'Do not run workflow jobs'], - ['value' => true, 'label' => 'Run workflow jobs on build servers'], + ['value' => true, 'label' => 'Run workflow jobs on your servers'], ]" :helper="filled($github_app->organization) ? 'Adds the organization Self-hosted runners (write) and Actions (read) permissions and the Workflow job webhook event.' diff --git a/resources/views/livewire/source/github/create.blade.php b/resources/views/livewire/source/github/create.blade.php index e61a029f37..d60517d4c7 100644 --- a/resources/views/livewire/source/github/create.blade.php +++ b/resources/views/livewire/source/github/create.blade.php @@ -37,7 +37,7 @@ + helper="Runs workflow jobs on your servers. Needs an organization." />
diff --git a/resources/views/livewire/source/github/permissions.blade.php b/resources/views/livewire/source/github/permissions.blade.php index 6f6884ceed..ef2162e902 100644 --- a/resources/views/livewire/source/github/permissions.blade.php +++ b/resources/views/livewire/source/github/permissions.blade.php @@ -27,7 +27,7 @@ @php($missingRunnerRequirements = $github_app->missingRunnerRequirements()) + description="Your servers can run the organization's workflow jobs in Docker containers. Configure them in the GitHub Runners menu of a server.">
assertJsonPath('is_sentinel_enabled', true) ->assertJsonPath('is_metrics_enabled', true) ->assertJsonPath('traffic_topn', 50) - ->assertJsonPath('is_geoip_enabled', true); + ->assertJsonPath('is_geoip_enabled', true) + ->assertJsonPath('traffic_ip_mode', 'full'); expect($response->json())->not->toHaveKey('sentinel_token') ->and($response->json())->not->toHaveKey('sentinel_custom_url'); @@ -247,6 +248,32 @@ describe('Sentinel API', function () { ->toHaveCount(1); }); + test('PATCH updates the client IP mode and restarts Sentinel', function (string $mode) { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ + 'traffic_ip_mode' => $mode, + ]) + ->assertOk() + ->assertJsonPath('traffic_ip_mode', $mode); + + expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe($mode); + expect(Queue::pushed(JobDecorator::class, fn (JobDecorator $job): bool => $job->getAction() instanceof StartSentinel)) + ->toHaveCount(1); + })->with(['anonymized', 'off']); + + test('PATCH rejects an unknown client IP mode', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ + 'traffic_ip_mode' => 'partial', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('traffic_ip_mode'); + + expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe('full'); + }); + test('PATCH rejects disabling mandatory Sentinel', function () { $this->withHeaders(serverSubsystemsHeaders()) ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ diff --git a/tests/Feature/GithubRunnerFlowTest.php b/tests/Feature/GithubRunnerFlowTest.php index 37663a26a9..d6d0ea49bd 100644 --- a/tests/Feature/GithubRunnerFlowTest.php +++ b/tests/Feature/GithubRunnerFlowTest.php @@ -480,14 +480,14 @@ describe('workflow_job webhook', function () { }); describe('provisioning', function () { - it('starts a runner on the least busy matching build server', function () { + it('starts a runner on the least busy matching server that can build', function () { Queue::fake(); fakeRunnerGithubApi($this->githubApp); Process::fake(['*' => Process::result(output: '29.8.0')]); $busyServer = runnerTestServer($this->team); $busyConfig = runnerTestConfig($busyServer, $this->githubApp); runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $busyServer->id, 'github_runner_config_id' => $busyConfig->id]); - $freeServer = runnerTestServer($this->team); + $freeServer = runnerTestServer($this->team, 'both'); runnerTestConfig($freeServer, $this->githubApp); $execution = runnerTestExecution($this->githubApp); @@ -615,7 +615,7 @@ describe('provisioning', function () { Process::assertNothingRan(); }); - it('does not use servers without the build role, disabled configs, or other labels', function (string $role, array $configAttributes) { + it('does not use deployments only servers, disabled configs, or other labels', function (string $role, array $configAttributes) { Queue::fake(); Process::fake(); $server = runnerTestServer($this->team, $role); @@ -627,7 +627,7 @@ describe('provisioning', function () { expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued); Process::assertNothingRan(); })->with([ - 'combined role' => ['both', []], + 'deployments only role' => ['deployment', []], 'disabled config' => ['build', ['is_enabled' => false]], 'other labels' => ['build', ['labels' => ['gpu']]], ]); diff --git a/tests/Feature/GithubRunnerSettingsTest.php b/tests/Feature/GithubRunnerSettingsTest.php index 65052a0fcb..e06a072e8e 100644 --- a/tests/Feature/GithubRunnerSettingsTest.php +++ b/tests/Feature/GithubRunnerSettingsTest.php @@ -268,21 +268,47 @@ describe('runner settings page', function () { ->assertRedirect(route('server.index')); }); - it('refuses to save runners on a server without the build role', function () { + it('asks for confirmation before it enables runners on a server that also runs resources', function () { $server = settingsTestServer($this->team, 'both'); + fakeRunnerGroupApi($this->githubApp); + + Livewire::actingAs($this->owner) + ->test(GithubRunners::class, ['server_uuid' => $server->uuid]) + ->assertSee('Enable runners on this server?') + ->assertDontSee('Application builds') + ->call('toggleEnabled') + ->assertDispatched('success') + ->assertSee('Runners share this server with your resources'); + + expect(GithubRunnerConfig::sole())->server_id->toBe($server->id)->is_enabled->toBeTrue(); + }); + + it('refuses runners on a deployments only server', function () { + $server = settingsTestServer($this->team, 'deployment'); Http::fake(); Livewire::actingAs($this->owner) ->test(GithubRunners::class, ['server_uuid' => $server->uuid]) ->assertSee('This server is not enabled for builds') - ->assertSee(route('server.show', ['server_uuid' => $server->uuid])) - ->assertSee(route('server.github-runners', ['server_uuid' => $server->uuid])) ->assertDontSee('Recent runners') ->set('githubAppId', $this->githubApp->id) - ->call('submit') + ->call('toggleEnabled') ->assertDispatched('error'); expect(GithubRunnerConfig::count())->toBe(0); + Http::assertNothingSent(); + }); + + it('enables runners on a build server without a confirmation', function () { + fakeRunnerGroupApi($this->githubApp); + + Livewire::actingAs($this->owner) + ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->assertDontSee('Enable runners on this server?') + ->call('toggleEnabled') + ->assertDispatched('success') + ->assertSee('Application builds') + ->assertDontSee('Runners share this server with your resources'); }); it('disables runners and removes only the idle ones', function () { @@ -539,25 +565,49 @@ describe('build server role', function () { GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]); }); - it('blocks a role change in the UI while runners are enabled', function () { + it('changes the role in the UI while runners are enabled', function () { Livewire::actingAs($this->owner) ->test(Show::class, ['server_uuid' => $this->server->uuid]) ->set('serverRole', 'both') ->call('requestServerRoleChange') + ->assertNotDispatched('error') + ->assertDispatched('open-server-role-confirmation') + ->call('confirmServerRoleChange'); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); + }); + + it('blocks a change to deployments only in the UI while runners are enabled', function () { + Livewire::actingAs($this->owner) + ->test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('serverRole', 'deployment') + ->call('requestServerRoleChange') ->assertSet('serverRole', 'build') ->assertDispatched('error'); expect($this->server->settings->fresh()->server_role->value)->toBe('build'); }); - it('blocks a role change through the API while runners are enabled', function () { + it('blocks a change to deployments only through the API while runners are enabled', function () { + $token = $this->owner->createToken('runner-test', ['*']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + settingsTestServer($this->team, 'build'); + + $this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken]) + ->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'deployment']) + ->assertStatus(422) + ->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you set this server to deployments only.'); + }); + + it('changes the role through the API while runners are enabled', function () { $token = $this->owner->createToken('runner-test', ['*']); $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken]) ->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'both']) - ->assertStatus(422) - ->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you change the role of this server.'); + ->assertSuccessful(); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); }); it('leaves dedicated runner servers out of application builds', function () { diff --git a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php index f347165646..3c4bf8f49f 100644 --- a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php @@ -200,6 +200,24 @@ it('decorates per-app paths with the app domain and surfaces AI agents', functio expect($component->instance()->topPaths[0]['domain'])->toBe('api.example.com'); }); +it('hides the IP breakdown when the server does not record client IPs', function () { + $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true); + $server = $application->destination->server; + $server->settings->traffic_ip_mode = 'off'; + // Saved quietly: a mode change restarts Sentinel, which needs SSH. + $server->settings->saveQuietly(); + + $fake = new FakeAnalyticsTrafficClient($server); + $fake->responses = fakeAnalyticsResponses(); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + loadLazy(Livewire::test(Analytics::class, ['application' => $application->fresh()])) + ->assertOk() + ->assertSee('Top user agents') + ->assertDontSee('Top IPs') + ->assertDontSee('198.51.100.42'); +}); + it('shows an empty state when traffic analytics is disabled for the server', function () { $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false); diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php index f6007d7e00..58add72c10 100644 --- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php @@ -1,5 +1,6 @@ and($fake->urls)->not->toBeEmpty() ->and(array_values(array_diff($fake->urls, $firstUrls)))->toBe([]); }); + +it('labels the IP breakdown by the client IP mode of the queried servers', function (string $mode, ?string $label) { + $server = bootEnabledGlobalServer(); + $server->settings->traffic_ip_mode = $mode; + // Saved quietly: a mode change restarts Sentinel, which needs SSH. + $server->settings->saveQuietly(); + + $fake = new FakeGlobalAnalyticsTrafficClient($server); + $fake->responses = fakeGlobalAnalyticsResponses(); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + $component = loadLazy(Livewire::test(Analytics::class)) + ->assertOk() + ->assertSet('ipMode', $mode) + ->assertSee('Top user agents'); + + if ($label === null) { + $component->assertDontSee('Top IPs') + ->assertDontSee('Top networks') + ->assertDontSee('203.0.113.7'); + expect($component->instance()->breakdowns)->not->toHaveKey('ip'); + + return; + } + + $component->assertSee($label)->assertSee('203.0.113.7'); +})->with([ + 'full' => ['full', 'Top IPs'], + 'anonymized' => ['anonymized', 'Top networks'], + 'off' => ['off', null], +]); + +it('combines the client IP mode of several servers', function (array $modes, string $expected) { + $servers = collect($modes)->map(function (string $mode) { + $server = bootEnabledGlobalServer(); + $server->settings->traffic_ip_mode = $mode; + // Saved quietly: a mode change restarts Sentinel, which needs SSH. + $server->settings->saveQuietly(); + + return $server->fresh(); + }); + + expect(TrafficIpMode::forServers($servers))->toBe(TrafficIpMode::from($expected)); +})->with([ + 'no servers' => [[], 'full'], + 'all off' => [['off', 'off'], 'off'], + 'anonymized and off' => [['anonymized', 'off'], 'anonymized'], + 'full and anonymized' => [['full', 'anonymized'], 'full'], +]); diff --git a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php index 0f23a64cbf..9d53564fb7 100644 --- a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php @@ -44,9 +44,19 @@ it('produces traffic + geoip env when enabled', function () { 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); diff --git a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php index 964942eb71..17bc84d4ef 100644 --- a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php @@ -2,6 +2,7 @@ use App\Actions\Server\ConfigureTrafficAnalytics; use App\Enums\ServerRole; +use App\Enums\TrafficIpMode; use App\Livewire\Analytics; use App\Livewire\Server\TrafficAnalyticsSettings; use App\Models\InstanceSettings; @@ -105,6 +106,51 @@ it('saves traffic analytics settings from the sentinel form', function () { ->and($settings->geoip_refresh_days)->toBe(7); }); +it('saves the client IP mode from the sentinel form', function () { + Queue::fake(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->save(); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) + ->assertSet('trafficIpMode', 'full') + ->assertSee('Client IP addresses') + ->set('trafficIpMode', 'anonymized') + ->call('saveTrafficAnalyticsSettings') + ->assertHasNoErrors(); + + expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Anonymized); +}); + +it('rejects an unknown client IP mode', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) + ->set('trafficIpMode', 'partial') + ->call('saveTrafficAnalyticsSettings') + ->assertHasErrors(['trafficIpMode']); + + expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full); +}); + +it('does not let a team member change the client IP mode', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->save(); + + $component = Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]); + + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + + $component->set('trafficIpMode', 'off') + ->call('saveTrafficAnalyticsSettings'); + + expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full); +}); + it('rejects a zero top-n cap', function () { $server = Server::factory()->create(['team_id' => $this->team->id]);