fix(sentinel): stop rejected Sentinel pushes and reduce their audit rows

- Reply 410 for deleted servers and 402 for unpaid teams, with Retry-After
- Remove the Sentinel container of unpaid servers once a day, and before a server is deleted
- Record server_not_found and subscription_unpaid push failures once per hour
- Audit scheduled task API updates only for fields that really changed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 23:19:14 +01:00
co-authored by Claude Opus 5.5
parent 9a6cc8316e
commit 5c34a1b3d4
4 changed files with 44 additions and 6 deletions
+21
View File
@@ -24,6 +24,11 @@ class DeleteServer
$deleteFromHetzner = $deleteFromVultr = $deleteFromDigitalOcean = $deleteFromHostinger = false;
}
// A Sentinel that keeps running would push to Coolify for a server that no longer exists.
if ($server && ! $server->isTransferredAway() && ! $deleteFromHetzner && ! $deleteFromVultr && ! $deleteFromDigitalOcean) {
$this->stopSentinel($server);
}
// Delete from Hetzner even if server is already gone from Coolify
if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) {
$this->deleteFromHetznerById(
@@ -74,6 +79,22 @@ class DeleteServer
}
}
private function stopSentinel(Server $server): void
{
if (! $server->settings?->is_sentinel_enabled || ! $server->isFunctional()) {
return;
}
try {
instant_remote_process(['docker rm -f coolify-sentinel'], $server, false, timeout: 30, maxAttempts: 1);
} catch (\Throwable $e) {
logger()->warning('Failed to stop Sentinel before deleting the server', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
}
}
private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
@@ -205,7 +205,13 @@ class ScheduledTasksController extends Controller
return $response;
}
$task->update($request->only($allowedFields));
$task->fill($request->only($allowedFields));
$changedFields = auditChangedFields($task);
$task->save();
if ($changedFields === []) {
return response()->json($this->removeSensitiveData($task), 200);
}
auditLog('api.scheduled_task.updated', [
'team_id' => getTeamIdFromToken(),
@@ -213,7 +219,7 @@ class ScheduledTasksController extends Controller
'task_name' => $task->name,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
'changed_fields' => $changedFields,
]);
return response()->json($this->removeSensitiveData($task), 200);
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\StopSentinel;
use App\Events\SentinelSynchronized;
use App\Http\Controllers\Controller;
use App\Jobs\CheckAndStartSentinelJob;
@@ -52,7 +53,8 @@ class SentinelController extends Controller
'server_uuid' => $server_uuid,
]);
return response()->json(['message' => 'Server not found'], 404);
// 410 and Retry-After make Sentinel wait one day before it pushes again.
return response()->json(['message' => 'Server not found'], 410, ['Retry-After' => 86400]);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
@@ -61,8 +63,12 @@ class SentinelController extends Controller
'team_id' => $server->team_id,
]);
$server->rememberSentinelPushProblem('Coolify rejected the push: the subscription of this team is not active.');
// Remove the Sentinel container of the unpaid server once a day. Server validation starts it again after payment.
if (Cache::add("sentinel:stop-unpaid:{$server->id}", true, now()->addDay())) {
StopSentinel::dispatch($server);
}
return response()->json(['message' => 'Unauthorized'], 401);
return response()->json(['message' => 'Subscription is not active'], 402, ['Retry-After' => 3600]);
}
if ($server->isFunctional() === false) {
+7 -2
View File
@@ -77,11 +77,16 @@ if (! function_exists('auditFailureDeduplicationWindow')) {
*
* Failed logins use a short window so an ongoing brute force attempt stays visible
* (one row per email and source IP each minute, on top of the login rate limit).
* Sentinel pushes for a deleted server or an unpaid team fail until someone acts, so one row each hour is enough.
*
* @param array<string, mixed> $context
*/
function auditFailureDeduplicationWindow(string $event): ?int
function auditFailureDeduplicationWindow(string $event, array $context = []): ?int
{
return match (true) {
$event === 'auth.user.login_failed' => 60,
$event === 'webhook.sentinel.signature_failed'
&& in_array(data_get($context, 'reason'), ['server_not_found', 'subscription_unpaid'], true) => 3600,
$event === 'api.auth.unauthenticated',
preg_match('/^webhook\.[a-z0-9_-]+\.signature_failed$/i', $event) === 1 => 300,
default => null,
@@ -98,7 +103,7 @@ if (! function_exists('shouldRecordAuditEvent')) {
*/
function shouldRecordAuditEvent(string $event, array $context): bool
{
$window = auditFailureDeduplicationWindow($event);
$window = auditFailureDeduplicationWindow($event, $context);
if ($window === null) {
return true;
}