Files
coolify/app/Http/Controllers/Api/ServerSentinelController.php
T
Andras Bacsai 575bfaa085 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.
2026-10-07 12:35:22 +02:00

248 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Enums\TrafficIpMode;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\ServerSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ServerSentinelController extends Controller
{
private const ALLOWED_FIELDS = [
'is_metrics_enabled',
'is_sentinel_debug_enabled',
'sentinel_token',
'sentinel_metrics_refresh_rate_seconds',
'sentinel_metrics_history_days',
'sentinel_push_interval_seconds',
'sentinel_custom_url',
'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',
];
private function findServerForTeam(int $teamId, string $uuid): ?Server
{
return Server::whereTeamId($teamId)->whereUuid($uuid)->first();
}
private function canReadSensitive(): bool
{
return request()->attributes->get('can_read_sensitive', false) === true;
}
private function transform(Server $server): array
{
$settings = $server->settings;
$payload = [
'is_sentinel_enabled' => $server->isSentinelEnabled(),
'is_metrics_enabled' => (bool) $settings->is_metrics_enabled,
'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled,
'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds,
'sentinel_metrics_history_days' => (int) $settings->sentinel_metrics_history_days,
'sentinel_push_interval_seconds' => (int) $settings->sentinel_push_interval_seconds,
'sentinel_updated_at' => $server->sentinel_updated_at,
'traffic_topn' => (int) $settings->traffic_topn,
'traffic_sample_threshold' => (int) $settings->traffic_sample_threshold,
'traffic_retention_1h_days' => (int) $settings->traffic_retention_1h_days,
'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()) {
$payload['sentinel_token'] = $settings->sentinel_token;
$payload['sentinel_custom_url'] = $settings->sentinel_custom_url;
$payload['geoip_maxmind_license_key'] = $settings->geoip_maxmind_license_key;
}
return $payload;
}
#[OA\Get(
summary: 'Get Sentinel settings',
description: 'Get Sentinel settings for a server owned by the authenticated team. sentinel_token and sentinel_custom_url require the read:sensitive or root token ability.',
path: '/servers/{uuid}/sentinel',
operationId: 'get-server-sentinel',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Sentinel settings.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean', readOnly: true, description: 'Sentinel is mandatory on regular managed servers.'),
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'),
new OA\Property(property: 'sentinel_metrics_refresh_rate_seconds', type: 'integer'),
new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer'),
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer'),
new OA\Property(property: 'sentinel_custom_url', type: 'string', description: 'Only present with read:sensitive.'),
new OA\Property(property: 'sentinel_updated_at', type: 'string', nullable: true),
new OA\Property(property: 'traffic_topn', type: 'integer'),
new OA\Property(property: 'traffic_sample_threshold', type: 'integer'),
new OA\Property(property: 'traffic_retention_1h_days', type: 'integer'),
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',
),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = $this->findServerForTeam($teamId, $request->uuid);
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
return response()->json($this->transform($server));
}
#[OA\Patch(
summary: 'Update Sentinel settings',
description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.',
path: '/servers/{uuid}/sentinel',
operationId: 'update-server-sentinel',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
new OA\Property(property: 'sentinel_token', type: 'string'),
new OA\Property(property: 'sentinel_metrics_refresh_rate_seconds', type: 'integer', minimum: 1),
new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer', minimum: 1),
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer', minimum: 10),
new OA\Property(property: 'sentinel_custom_url', type: 'string', nullable: true),
new OA\Property(property: 'traffic_topn', type: 'integer', maximum: 1000, minimum: 1),
new OA\Property(property: 'traffic_sample_threshold', type: 'integer', minimum: 0),
new OA\Property(property: 'traffic_retention_1h_days', type: 'integer', maximum: 365, minimum: 1),
new OA\Property(property: 'traffic_retention_1d_days', type: 'integer', maximum: 3650, minimum: 1),
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',
),
),
responses: [
new OA\Response(response: 200, description: 'Updated Sentinel settings.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function update(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$server = $this->findServerForTeam($teamId, $request->uuid);
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
$validator = customApiValidator($request->all(), [
'is_metrics_enabled' => 'boolean',
'is_sentinel_debug_enabled' => 'boolean',
'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'],
'sentinel_metrics_refresh_rate_seconds' => 'integer|min:1',
'sentinel_metrics_history_days' => 'integer|min:1',
'sentinel_push_interval_seconds' => 'integer|min:10',
'sentinel_custom_url' => 'nullable|url',
'traffic_topn' => 'integer|min:1|max:1000',
'traffic_sample_threshold' => 'integer|min:0',
'traffic_retention_1h_days' => 'integer|min:1|max:365',
'traffic_retention_1d_days' => 'integer|min:1|max:3650',
'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);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->has('sentinel_token') && ! ServerSetting::isValidSentinelToken($request->input('sentinel_token'))) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['sentinel_token' => ['Invalid sentinel token characters.']],
], 422);
}
$settings = $server->settings;
foreach (self::ALLOWED_FIELDS as $field) {
if ($request->has($field)) {
$settings->{$field} = $request->input($field);
}
}
$settings->save();
auditLog('api.server.sentinel.updated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'changed_fields' => array_values(array_intersect(self::ALLOWED_FIELDS, array_keys($request->all()))),
]);
return response()->json($this->transform($server->refresh()));
}
}