From 575bfaa08564f90031dc7883e917ff72914b14b7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:35:22 +0200 Subject: [PATCH] feat(analytics): add configurable client IP storage modes Allow full, anonymized, or disabled IP recording per server through the UI and API, and pass the selected mode to Sentinel. Adapt traffic breakdowns to the effective IP mode, including views that aggregate multiple servers. --- .ai/lessons.md | 1 + app/Actions/Server/StartSentinel.php | 2 + app/Enums/TrafficIpMode.php | 72 +++++++++++++++++++ .../Api/ServerSentinelController.php | 6 ++ app/Livewire/Analytics.php | 12 +++- .../Shared/ResourceTrafficAnalytics.php | 12 +++- .../Server/TrafficAnalyticsSettings.php | 8 ++- app/Models/ServerSetting.php | 7 +- ...add_traffic_ip_mode_to_server_settings.php | 28 ++++++++ openapi.json | 55 ++++++++++++-- openapi.yaml | 34 +++++++-- resources/views/livewire/analytics.blade.php | 15 ++-- .../project/application/analytics.blade.php | 15 ++-- .../traffic-analytics-settings.blade.php | 10 ++- tests/Feature/Api/ServerSubsystemsApiTest.php | 29 +++++++- .../ApplicationAnalyticsTest.php | 18 +++++ .../TrafficAnalytics/GlobalAnalyticsTest.php | 50 +++++++++++++ .../StartSentinelTrafficTest.php | 10 +++ .../ToggleTrafficAnalyticsTest.php | 46 ++++++++++++ 19 files changed, 399 insertions(+), 31 deletions(-) create mode 100644 app/Enums/TrafficIpMode.php create mode 100644 database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php 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]);