feat: notification rate limiter

fix: limit server up / down notification limits
This commit is contained in:
Andras Bacsai
2024-10-25 15:13:23 +02:00
parent fb75741aa8
commit 8c96ab52d7
9 changed files with 161 additions and 217 deletions

View File

@@ -110,11 +110,11 @@ class Kernel extends ConsoleKernel
$servers = $this->allServers->where('ip', '!=', '1.2.3.4'); $servers = $this->allServers->where('ip', '!=', '1.2.3.4');
} }
foreach ($servers as $server) { foreach ($servers as $server) {
$last_sentinel_update = $server->sentinel_updated_at; $lastSentinelUpdate = $server->sentinel_updated_at;
if (Carbon::parse($last_sentinel_update)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) { $serverTimezone = $server->settings->server_timezone;
if (Carbon::parse($lastSentinelUpdate)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) {
$schedule->job(new ServerCheckJob($server))->everyMinute()->onOneServer(); $schedule->job(new ServerCheckJob($server))->everyMinute()->onOneServer();
} }
$serverTimezone = $server->settings->server_timezone;
if ($server->settings->force_docker_cleanup) { if ($server->settings->force_docker_cleanup) {
$schedule->job(new DockerCleanupJob($server))->cron($server->settings->docker_cleanup_frequency)->timezone($serverTimezone)->onOneServer(); $schedule->job(new DockerCleanupJob($server))->cron($server->settings->docker_cleanup_frequency)->timezone($serverTimezone)->onOneServer();
} else { } else {

View File

@@ -97,8 +97,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
} }
$data = collect($this->data); $data = collect($this->data);
$this->serverStatus();
$this->server->sentinelHeartbeat(); $this->server->sentinelHeartbeat();
$this->containers = collect(data_get($data, 'containers')); $this->containers = collect(data_get($data, 'containers'));
@@ -212,16 +210,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
} }
private function serverStatus()
{
if ($this->server->isFunctional() === false) {
throw new \Exception('Server is not ready.');
}
if ($this->server->status() === false) {
throw new \Exception('Server is not reachable.');
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus) private function updateApplicationStatus(string $applicationId, string $containerStatus)
{ {
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applications->where('id', $applicationId)->first();

View File

@@ -43,22 +43,15 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function handle() public function handle()
{ {
try { try {
if ($this->server->serverStatus() === false) {
return 'Server is not reachable or not ready.';
}
$this->applications = $this->server->applications(); $this->applications = $this->server->applications();
$this->databases = $this->server->databases(); $this->databases = $this->server->databases();
$this->services = $this->server->services()->get(); $this->services = $this->server->services()->get();
$this->previews = $this->server->previews(); $this->previews = $this->server->previews();
$up = $this->serverStatus();
if (! $up) {
ray('Server is not reachable.');
return 'Server is not reachable.';
}
if (! $this->server->isFunctional()) {
ray('Server is not ready.');
return 'Server is not ready.';
}
if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) { if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers(); ['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
if (is_null($this->containers)) { if (is_null($this->containers)) {
@@ -111,39 +104,6 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
} }
private function serverStatus()
{
['uptime' => $uptime] = $this->server->validateConnection(false);
if ($uptime) {
if ($this->server->unreachable_notification_sent === true) {
$this->server->update(['unreachable_notification_sent' => false]);
}
} else {
// $this->server->team?->notify(new Unreachable($this->server));
foreach ($this->applications as $application) {
$application->update(['status' => 'exited']);
}
foreach ($this->databases as $database) {
$database->update(['status' => 'exited']);
}
foreach ($this->services as $service) {
$apps = $service->applications()->get();
$dbs = $service->databases()->get();
foreach ($apps as $app) {
$app->update(['status' => 'exited']);
}
foreach ($dbs as $db) {
$db->update(['status' => 'exited']);
}
}
return false;
}
return true;
}
private function checkLogDrainContainer() private function checkLogDrainContainer()
{ {
$foundLogDrainContainer = $this->containers->filter(function ($value, $key) { $foundLogDrainContainer = $this->containers->filter(function ($value, $key) {

View File

@@ -6,6 +6,8 @@ use App\Actions\Server\InstallDocker;
use App\Actions\Server\StartSentinel; use App\Actions\Server\StartSentinel;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckAndStartSentinelJob;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@@ -61,6 +63,7 @@ class Server extends BaseModel
$payload['ip'] = str($server->ip)->trim(); $payload['ip'] = str($server->ip)->trim();
} }
$server->forceFill($payload); $server->forceFill($payload);
}); });
static::created(function ($server) { static::created(function ($server) {
ServerSetting::create([ ServerSetting::create([
@@ -107,12 +110,15 @@ class Server extends BaseModel
}); });
} }
public $casts = [ protected $casts = [
'proxy' => SchemalessAttributes::class, 'proxy' => SchemalessAttributes::class,
'logdrain_axiom_api_key' => 'encrypted', 'logdrain_axiom_api_key' => 'encrypted',
'logdrain_newrelic_license_key' => 'encrypted', 'logdrain_newrelic_license_key' => 'encrypted',
'delete_unused_volumes' => 'boolean', 'delete_unused_volumes' => 'boolean',
'delete_unused_networks' => 'boolean', 'delete_unused_networks' => 'boolean',
'unreachable_notification_sent' => 'boolean',
'is_build_server' => 'boolean',
'force_disabled' => 'boolean',
]; ];
protected $schemalessAttributes = [ protected $schemalessAttributes = [
@@ -519,16 +525,14 @@ $schema://$host {
public function forceEnableServer() public function forceEnableServer()
{ {
$this->settings->update([ $this->settings->force_disabled = false;
'force_disabled' => false, $this->settings->save();
]);
} }
public function forceDisableServer() public function forceDisableServer()
{ {
$this->settings->update([ $this->settings->force_disabled = true;
'force_disabled' => true, $this->settings->save();
]);
$sshKeyFileLocation = "id.root@{$this->uuid}"; $sshKeyFileLocation = "id.root@{$this->uuid}";
Storage::disk('ssh-keys')->delete($sshKeyFileLocation); Storage::disk('ssh-keys')->delete($sshKeyFileLocation);
Storage::disk('ssh-mux')->delete($this->muxFilename()); Storage::disk('ssh-mux')->delete($this->muxFilename());
@@ -624,72 +628,6 @@ $schema://$host {
} }
} }
public function isServerReady(int $tries = 3)
{
if ($this->skipServer()) {
return false;
}
$serverUptimeCheckNumber = $this->unreachable_count;
if ($this->unreachable_count < $tries) {
$serverUptimeCheckNumber = $this->unreachable_count + 1;
}
if ($this->unreachable_count > $tries) {
$serverUptimeCheckNumber = $tries;
}
$serverUptimeCheckNumberMax = $tries;
// ray('server: ' . $this->name);
// ray('serverUptimeCheckNumber: ' . $serverUptimeCheckNumber);
// ray('serverUptimeCheckNumberMax: ' . $serverUptimeCheckNumberMax);
['uptime' => $uptime] = $this->validateConnection();
if ($uptime) {
if ($this->unreachable_notification_sent === true) {
$this->update(['unreachable_notification_sent' => false]);
}
return true;
} else {
if ($serverUptimeCheckNumber >= $serverUptimeCheckNumberMax) {
// Reached max number of retries
if ($this->unreachable_notification_sent === false) {
ray('Server unreachable, sending notification...');
// $this->team?->notify(new Unreachable($this));
$this->update(['unreachable_notification_sent' => true]);
}
if ($this->settings->is_reachable === true) {
$this->settings()->update([
'is_reachable' => false,
]);
}
foreach ($this->applications() as $application) {
$application->update(['status' => 'exited']);
}
foreach ($this->databases() as $database) {
$database->update(['status' => 'exited']);
}
foreach ($this->services()->get() as $service) {
$apps = $service->applications()->get();
$dbs = $service->databases()->get();
foreach ($apps as $app) {
$app->update(['status' => 'exited']);
}
foreach ($dbs as $db) {
$db->update(['status' => 'exited']);
}
}
} else {
$this->update([
'unreachable_count' => $this->unreachable_count + 1,
]);
}
return false;
}
}
public function getDiskUsage(): ?string public function getDiskUsage(): ?string
{ {
return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false); return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false);
@@ -1038,29 +976,43 @@ $schema://$host {
return data_get($this, 'settings.is_swarm_worker'); return data_get($this, 'settings.is_swarm_worker');
} }
public function serverStatus(): bool
{
if ($this->status() === false) {
return false;
}
if ($this->isFunctional() === false) {
return false;
}
return true;
}
public function status(): bool public function status(): bool
{ {
if ($this->skipServer()) {
return false;
}
['uptime' => $uptime] = $this->validateConnection(false); ['uptime' => $uptime] = $this->validateConnection(false);
if ($uptime) { if ($uptime === false) {
if ($this->unreachable_notification_sent === true) { foreach ($this->applications() as $application) {
$this->update(['unreachable_notification_sent' => false]); $application->status = 'exited';
$application->save();
} }
} else { foreach ($this->databases() as $database) {
// $this->server->team?->notify(new Unreachable($this->server)); $database->status = 'exited';
foreach ($this->applications as $application) { $database->save();
$application->update(['status' => 'exited']);
} }
foreach ($this->databases as $database) { foreach ($this->services() as $service) {
$database->update(['status' => 'exited']);
}
foreach ($this->services as $service) {
$apps = $service->applications()->get(); $apps = $service->applications()->get();
$dbs = $service->databases()->get(); $dbs = $service->databases()->get();
foreach ($apps as $app) { foreach ($apps as $app) {
$app->update(['status' => 'exited']); $app->status = 'exited';
$app->save();
} }
foreach ($dbs as $db) { foreach ($dbs as $db) {
$db->update(['status' => 'exited']); $db->status = 'exited';
$db->save();
} }
} }
@@ -1070,39 +1022,65 @@ $schema://$host {
return true; return true;
} }
public function isReachableChanged()
{
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
loggy('Server setting is_reachable changed to '.$isReachable.' for server '.$this->id.'. Unreachable notification sent: '.$unreachableNotificationSent);
// If the server is reachable, send the reachable notification if it was sent before
if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
} else {
// If the server is unreachable, send the unreachable notification if it was not sent before
if ($unreachableNotificationSent === false) {
$this->sendUnreachableNotification();
}
}
}
public function sendReachableNotification()
{
$this->unreachable_notification_sent = false;
$this->save();
$this->refresh();
$this->team->notify(new Reachable($this));
}
public function sendUnreachableNotification()
{
$this->unreachable_notification_sent = true;
$this->save();
$this->refresh();
$this->team->notify(new Unreachable($this));
}
public function validateConnection($isManualCheck = true) public function validateConnection($isManualCheck = true)
{ {
config()->set('constants.ssh.mux_enabled', ! $isManualCheck); config()->set('constants.ssh.mux_enabled', ! $isManualCheck);
// ray('Manual Check: ' . ($isManualCheck ? 'true' : 'false'));
$server = Server::find($this->id); if ($this->skipServer()) {
if (! $server) {
return ['uptime' => false, 'error' => 'Server not found.'];
}
if ($server->skipServer()) {
return ['uptime' => false, 'error' => 'Server skipped.']; return ['uptime' => false, 'error' => 'Server skipped.'];
} }
try { try {
// Make sure the private key is stored // Make sure the private key is stored
if ($server->privateKey) { if ($this->privateKey) {
$server->privateKey->storeInFileSystem(); $this->privateKey->storeInFileSystem();
} }
instant_remote_process(['ls /'], $server); instant_remote_process(['ls /'], $this);
$server->settings()->update([ if ($this->settings->is_reachable === false) {
'is_reachable' => true, $this->settings->is_reachable = true;
]); $this->settings->save();
$server->update([
'unreachable_count' => 0,
]);
if (data_get($server, 'unreachable_notification_sent') === true) {
$server->update(['unreachable_notification_sent' => false]);
} }
return ['uptime' => true, 'error' => null]; return ['uptime' => true, 'error' => null];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$server->settings()->update([ if ($this->settings->is_reachable === true) {
'is_reachable' => false, $this->settings->is_reachable = false;
]); $this->settings->save();
}
return ['uptime' => false, 'error' => $e->getMessage()]; return ['uptime' => false, 'error' => $e->getMessage()];
} }

View File

@@ -54,6 +54,8 @@ class ServerSetting extends Model
'force_docker_cleanup' => 'boolean', 'force_docker_cleanup' => 'boolean',
'docker_cleanup_threshold' => 'integer', 'docker_cleanup_threshold' => 'integer',
'sentinel_token' => 'encrypted', 'sentinel_token' => 'encrypted',
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
]; ];
protected static function booted() protected static function booted()
@@ -70,15 +72,18 @@ class ServerSetting extends Model
loggy('Error creating server setting: '.$e->getMessage()); loggy('Error creating server setting: '.$e->getMessage());
} }
}); });
static::updated(function ($setting) { static::updated(function ($settings) {
if ( if (
$setting->isDirty('sentinel_token') || $settings->isDirty('sentinel_token') ||
$setting->isDirty('sentinel_custom_url') || $settings->isDirty('sentinel_custom_url') ||
$setting->isDirty('sentinel_metrics_refresh_rate_seconds') || $settings->isDirty('sentinel_metrics_refresh_rate_seconds') ||
$setting->isDirty('sentinel_metrics_history_days') || $settings->isDirty('sentinel_metrics_history_days') ||
$setting->isDirty('sentinel_push_interval_seconds') $settings->isDirty('sentinel_push_interval_seconds')
) { ) {
$setting->server->restartSentinel(); $settings->server->restartSentinel();
}
if ($settings->isDirty('is_reachable')) {
$settings->server->isReachableChanged();
} }
}); });
} }

View File

@@ -2,8 +2,6 @@
namespace App\Notifications\Server; namespace App\Notifications\Server;
use App\Actions\Docker\GetContainersStatus;
use App\Jobs\ContainerStatusJob;
use App\Models\Server; use App\Models\Server;
use App\Notifications\Channels\DiscordChannel; use App\Notifications\Channels\DiscordChannel;
use App\Notifications\Channels\EmailChannel; use App\Notifications\Channels\EmailChannel;
@@ -13,25 +11,28 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\RateLimiter;
class Revived extends Notification implements ShouldQueue class Reachable extends Notification implements ShouldQueue
{ {
use Queueable; use Queueable;
public $tries = 1; public $tries = 1;
protected bool $isRateLimited = false;
public function __construct(public Server $server) public function __construct(public Server $server)
{ {
if ($this->server->unreachable_notification_sent === false) { $this->isRateLimited = isEmailRateLimited(
return; limiterKey: 'server-reachable:'.$this->server->id,
} );
GetContainersStatus::dispatch($server)->onQueue('high');
// dispatch(new ContainerStatusJob($server));
} }
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
if ($this->isRateLimited) {
return [];
}
$channels = []; $channels = [];
$isEmailEnabled = isEmailEnabled($notifiable); $isEmailEnabled = isEmailEnabled($notifiable);
$isDiscordEnabled = data_get($notifiable, 'discord_enabled'); $isDiscordEnabled = data_get($notifiable, 'discord_enabled');
@@ -46,20 +47,8 @@ class Revived extends Notification implements ShouldQueue
if ($isTelegramEnabled) { if ($isTelegramEnabled) {
$channels[] = TelegramChannel::class; $channels[] = TelegramChannel::class;
} }
$executed = RateLimiter::attempt(
'notification-server-revived-'.$this->server->uuid,
1,
function () use ($channels) {
return $channels;
},
7200,
);
if (! $executed) { return $channels;
return [];
}
return $executed;
} }
public function toMail(): MailMessage public function toMail(): MailMessage

View File

@@ -11,7 +11,6 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\RateLimiter;
class Unreachable extends Notification implements ShouldQueue class Unreachable extends Notification implements ShouldQueue
{ {
@@ -19,10 +18,21 @@ class Unreachable extends Notification implements ShouldQueue
public $tries = 1; public $tries = 1;
public function __construct(public Server $server) {} protected bool $isRateLimited = false;
public function __construct(public Server $server)
{
$this->isRateLimited = isEmailRateLimited(
limiterKey: 'server-unreachable:'.$this->server->id,
);
}
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
if ($this->isRateLimited) {
return [];
}
$channels = []; $channels = [];
$isEmailEnabled = isEmailEnabled($notifiable); $isEmailEnabled = isEmailEnabled($notifiable);
$isDiscordEnabled = data_get($notifiable, 'discord_enabled'); $isDiscordEnabled = data_get($notifiable, 'discord_enabled');
@@ -37,23 +47,11 @@ class Unreachable extends Notification implements ShouldQueue
if ($isTelegramEnabled) { if ($isTelegramEnabled) {
$channels[] = TelegramChannel::class; $channels[] = TelegramChannel::class;
} }
$executed = RateLimiter::attempt(
'notification-server-unreachable-'.$this->server->uuid,
1,
function () use ($channels) {
return $channels;
},
7200,
);
if (! $executed) { return $channels;
return [];
}
return $executed;
} }
public function toMail(): MailMessage public function toMail(): ?MailMessage
{ {
$mail = new MailMessage; $mail = new MailMessage;
$mail->subject("Coolify: Your server ({$this->server->name}) is unreachable."); $mail->subject("Coolify: Your server ({$this->server->name}) is unreachable.");
@@ -64,7 +62,7 @@ class Unreachable extends Notification implements ShouldQueue
return $mail; return $mail;
} }
public function toDiscord(): DiscordMessage public function toDiscord(): ?DiscordMessage
{ {
$message = new DiscordMessage( $message = new DiscordMessage(
title: ':cross_mark: Server unreachable', title: ':cross_mark: Server unreachable',
@@ -77,7 +75,7 @@ class Unreachable extends Notification implements ShouldQueue
return $message; return $message;
} }
public function toTelegram(): array public function toTelegram(): ?array
{ {
return [ return [
'message' => "Coolify: Your server '{$this->server->name}' is unreachable. All automations & integrations are turned off! Please check your server! IMPORTANT: We automatically try to revive your server and turn on all automations & integrations.", 'message' => "Coolify: Your server '{$this->server->name}' is unreachable. All automations & integrations are turned off! Please check your server! IMPORTANT: We automatically try to revive your server and turn on all automations & integrations.",

View File

@@ -39,6 +39,7 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -4038,3 +4039,30 @@ function sslipDomainWarning(string $domains)
return $showSslipHttpsWarning; return $showSslipHttpsWarning;
} }
function isEmailRateLimited(string $limiterKey, int $decaySeconds = 3600, ?callable $callbackOnSuccess = null): bool
{
if (isDev()) {
$decaySeconds = 120;
}
$rateLimited = false;
$executed = RateLimiter::attempt(
$limiterKey,
$maxAttempts = 0,
function () use (&$rateLimited, &$limiterKey, $callbackOnSuccess) {
isDev() && loggy('Rate limit not reached for '.$limiterKey);
$rateLimited = false;
if ($callbackOnSuccess) {
$callbackOnSuccess();
}
},
$decaySeconds,
);
if (! $executed) {
isDev() && loggy('Rate limit reached for '.$limiterKey.'. Rate limiter will be disabled for '.$decaySeconds.' seconds.');
$rateLimited = true;
}
return $rateLimited;
}

View File

@@ -20,6 +20,9 @@ function help {
compgen -A function | cat -n compgen -A function | cat -n
} }
function logs {
docker exec -t coolify tail -f storage/logs/laravel.log
}
function test { function test {
docker exec -t coolify php artisan test --testsuite=Feature docker exec -t coolify php artisan test --testsuite=Feature
} }
@@ -35,11 +38,6 @@ function db:reset-prod {
bash spin exec -u webuser coolify php artisan migrate:fresh --force --seed --seeder=ProductionSeeder || bash spin exec -u webuser coolify php artisan migrate:fresh --force --seed --seeder=ProductionSeeder ||
php artisan migrate:fresh --force --seed --seeder=ProductionSeeder php artisan migrate:fresh --force --seed --seeder=ProductionSeeder
} }
function mfs {
db:reset
}
function coolify { function coolify {
bash spin exec -u webuser coolify bash bash spin exec -u webuser coolify bash
} }