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/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/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/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 9c89dcf413..99587429fa 100644 --- a/openapi.json +++ b/openapi.json @@ -991,7 +991,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", @@ -1582,7 +1582,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", @@ -2173,7 +2173,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", @@ -2659,7 +2659,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", @@ -3125,7 +3125,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", @@ -9161,6 +9161,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": [ @@ -9279,6 +9282,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": [ @@ -17045,6 +17051,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" @@ -17153,6 +17168,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" @@ -23224,6 +23248,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", @@ -23246,7 +23277,8 @@ "retention_amount_s3", "retention_days_s3", "retention_max_storage_s3", - "timeout" + "timeout", + "missing_backup_notification_days" ], "properties": { "uuid": { @@ -23308,6 +23340,9 @@ }, "timeout": { "type": "integer" + }, + "missing_backup_notification_days": { + "type": "integer" } }, "type": "object" @@ -24344,6 +24379,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 802aee71a7..e298549e80 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -655,7 +655,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 @@ -1041,7 +1041,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 @@ -1427,7 +1427,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 @@ -1754,7 +1754,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 @@ -2067,7 +2067,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 @@ -5985,6 +5985,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: [] @@ -6060,6 +6062,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: [] @@ -10758,6 +10762,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': @@ -10828,6 +10833,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': @@ -14741,6 +14750,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: @@ -14761,6 +14776,7 @@ components: - retention_days_s3 - retention_max_storage_s3 - timeout + - missing_backup_notification_days properties: uuid: type: string @@ -14803,6 +14819,8 @@ components: format: float timeout: type: integer + missing_backup_notification_days: + type: integer type: object Application: description: 'Application model' @@ -15562,6 +15580,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 ($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/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index 37699bff1f..1f24abd97e 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -221,7 +221,8 @@ describe('Sentinel API', function () { ->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/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]);