Merge remote-tracking branch 'origin/next' into fix/form-state

This commit is contained in:
Andras Bacsai
2026-05-25 16:08:19 +02:00
65 changed files with 4703 additions and 1945 deletions
+12
View File
@@ -15,6 +15,18 @@ DB_PASSWORD=password
DB_HOST=host.docker.internal
DB_PORT=5432
# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split.
# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset.
# DB_READ_HOST=replica1,replica2
# DB_READ_PORT=5432
# DB_READ_USERNAME=coolify
# DB_READ_PASSWORD=
# DB_WRITE_HOST=
# DB_WRITE_PORT=5432
# DB_WRITE_USERNAME=coolify
# DB_WRITE_PASSWORD=
# DB_STICKY=true
# Ray Configuration
# Set to true to enable Ray
RAY_ENABLED=false
+1
View File
@@ -59,6 +59,7 @@ Thank you so much!
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
+13 -9
View File
@@ -11,12 +11,16 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
class StartDatabase
{
use AsAction;
public string $jobQueue = 'high';
public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{
@@ -25,28 +29,28 @@ class StartDatabase
return 'Server is not functional';
}
switch ($database->getMorphClass()) {
case \App\Models\StandalonePostgresql::class:
case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
case \App\Models\StandaloneRedis::class:
case StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
case \App\Models\StandaloneMongodb::class:
case StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
case \App\Models\StandaloneMysql::class:
case StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
case \App\Models\StandaloneMariadb::class:
case StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
case \App\Models\StandaloneKeydb::class:
case StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
case \App\Models\StandaloneDragonfly::class:
case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
case \App\Models\StandaloneClickhouse::class:
case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
}
+8 -3
View File
@@ -11,14 +11,19 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Notifications\Container\ContainerRestarted;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartDatabaseProxy
{
use AsAction;
public string $jobQueue = 'high';
public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
{
@@ -29,7 +34,7 @@ class StartDatabaseProxy
$proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($database->getMorphClass() === ServiceDatabase::class) {
$databaseType = $database->databaseType();
$network = $database->service->uuid;
$server = data_get($database, 'service.destination.server');
@@ -132,7 +137,7 @@ class StartDatabaseProxy
?? data_get($database, 'service.environment.project.team');
$team?->notify(
new \App\Notifications\Container\ContainerRestarted(
new ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
)
+5 -1
View File
@@ -4,13 +4,17 @@ namespace App\Actions\Service;
use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartService
{
use AsAction;
public string $jobQueue = 'high';
public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{
+3 -2
View File
@@ -8,6 +8,7 @@ use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
@@ -40,7 +41,7 @@ class Kernel extends ConsoleKernel
$this->instanceTimezone = config('app.timezone');
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly()->onOneServer();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
@@ -78,7 +79,7 @@ class Kernel extends ConsoleKernel
// Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
+46 -212
View File
@@ -4,7 +4,6 @@ namespace App\Helpers;
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
@@ -12,145 +11,65 @@ use Illuminate\Support\Facades\Storage;
class SshMultiplexingHelper
{
public static function serverSshConfiguration(Server $server)
public static function serverSshConfiguration(Server $server): array
{
$privateKey = PrivateKey::findOrFail($server->private_key_id);
$sshKeyLocation = $privateKey->getKeyLocation();
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
return [
'sshKeyLocation' => $sshKeyLocation,
'muxFilename' => $muxFilename,
'sshKeyLocation' => $privateKey->getKeyLocation(),
'muxFilename' => self::muxSocket($server),
];
}
public static function ensureMultiplexedConnection(Server $server): bool
{
if (! self::isMultiplexingEnabled()) {
return false;
}
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
// Check if connection exists
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$checkCommand .= self::escapedUserAtHost($server);
$process = Process::run($checkCommand);
if ($process->exitCode() !== 0) {
return self::establishNewMultiplexedConnection($server);
}
// Connection exists, ensure we have metadata for age tracking
if (self::getConnectionAge($server) === null) {
// Existing connection but no metadata, store current time as fallback
self::storeConnectionMetadata($server);
}
// Connection exists, check if it needs refresh due to age
if (self::isConnectionExpired($server)) {
return self::refreshMultiplexedConnection($server);
}
// Perform health check if enabled
if (config('constants.ssh.mux_health_check_enabled')) {
if (! self::isConnectionHealthy($server)) {
return self::refreshMultiplexedConnection($server);
}
}
return true;
return self::isMultiplexingEnabled();
}
public static function establishNewMultiplexedConnection(Server $server): bool
public static function removeMuxFile(Server $server): void
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
$establishCommand .= self::escapedUserAtHost($server);
$establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) {
return false;
}
// Store connection metadata for tracking
self::storeConnectionMetadata($server);
return true;
}
public static function removeMuxFile(Server $server)
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$closeCommand .= self::escapedUserAtHost($server);
$closeCommand = self::muxControlCommand($server, 'exit');
Process::run($closeCommand);
// Clear connection metadata from cache
self::clearConnectionMetadata($server);
}
public static function generateScpCommand(Server $server, string $source, string $dest)
private static function muxControlCommand(Server $server, string $operation): string
{
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
return $command.self::escapedUserAtHost($server);
}
public static function generateScpCommand(Server $server, string $source, string $dest): string
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
$timeout = config('constants.ssh.command_timeout');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$scp_command = "timeout $timeout scp ";
if ($server->isIpv6()) {
$scp_command .= '-6 ';
$scpCommand .= '-6 ';
}
if (self::isMultiplexingEnabled()) {
try {
if (self::ensureMultiplexedConnection($server)) {
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
}
} catch (\Exception $e) {
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
// Continue without multiplexing
}
$scpCommand .= self::multiplexingOptions($server);
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
$scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
} else {
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
return $scpCommand."{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
}
return $scp_command;
return $scpCommand."{$source} ".self::escapedUserAtHost($server).":{$dest}";
}
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false)
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string
{
if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.');
@@ -161,40 +80,36 @@ class SshMultiplexingHelper
self::validateSshKey($server->privateKey);
$muxSocket = $sshConfig['muxFilename'];
$sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh ';
$timeout = config('constants.ssh.command_timeout');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$ssh_command = "timeout $timeout ssh ";
$multiplexingSuccessful = false;
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
try {
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
if ($multiplexingSuccessful) {
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
}
} catch (\Exception $e) {
// Continue without multiplexing
}
$sshCommand .= self::multiplexingOptions($server);
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
$sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command);
$delimiter = base64_encode($delimiter);
$delimiter = base64_encode(Hash::make($command));
$command = str_replace($delimiter, '', $command);
$ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
}
return $ssh_command;
private static function multiplexingOptions(Server $server): string
{
return '-o ControlMaster=auto '
.'-o ControlPath='.self::muxSocket($server).' '
.'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
}
private static function muxSocket(Server $server): string
{
return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
}
private static function escapedUserAtHost(Server $server): string
@@ -231,7 +146,6 @@ class SshMultiplexingHelper
$privateKey->storeInFileSystem();
}
// Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
@@ -262,90 +176,10 @@ class SshMultiplexingHelper
.'-o RequestTTY=no '
.'-o LogLevel=ERROR ';
// Bruh
if ($isScp) {
$options .= '-P '.escapeshellarg((string) $server->port).' ';
} else {
$options .= '-p '.escapeshellarg((string) $server->port).' ';
return $options.'-P '.escapeshellarg((string) $server->port).' ';
}
return $options;
}
/**
* Check if the multiplexed connection is healthy by running a test command
*/
public static function isConnectionHealthy(Server $server): bool
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
$process = Process::run($healthCommand);
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
return $isHealthy;
}
/**
* Check if the connection has exceeded its maximum age
*/
public static function isConnectionExpired(Server $server): bool
{
$connectionAge = self::getConnectionAge($server);
$maxAge = config('constants.ssh.mux_max_age');
return $connectionAge !== null && $connectionAge > $maxAge;
}
/**
* Get the age of the current connection in seconds
*/
public static function getConnectionAge(Server $server): ?int
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
$connectionTime = Cache::get($cacheKey);
if ($connectionTime === null) {
return null;
}
return time() - $connectionTime;
}
/**
* Refresh a multiplexed connection by closing and re-establishing it
*/
public static function refreshMultiplexedConnection(Server $server): bool
{
// Close existing connection
self::removeMuxFile($server);
// Establish new connection
return self::establishNewMultiplexedConnection($server);
}
/**
* Store connection metadata when a new connection is established
*/
private static function storeConnectionMetadata(Server $server): void
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
}
/**
* Clear connection metadata from cache
*/
private static function clearConnectionMetadata(Server $server): void
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
Cache::forget($cacheKey);
return $options.'-p '.escapeshellarg((string) $server->port).' ';
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SentinelController extends Controller
{
/**
* Handle a Sentinel agent metrics push.
*
* Sentinel pushes its full container list on a fixed interval (default 60s),
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
* server per minute, the job is only dispatched when the container state hash
* changes, or when the force window has elapsed.
*/
public function push(Request $request)
{
$token = $request->header('Authorization');
if (! $token) {
auditLogWebhookFailure('sentinel', 'token_missing');
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
try {
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
auditLogWebhookFailure('sentinel', 'decrypt_failed');
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
if (! $server_uuid) {
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
return response()->json(['message' => 'Invalid token'], 401);
}
$server = Server::where('uuid', $server_uuid)->first();
if (! $server) {
auditLogWebhookFailure('sentinel', 'server_not_found', [
'server_uuid' => $server_uuid,
]);
return response()->json(['message' => 'Server not found'], 404);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
if ($server->isFunctional() === false) {
auditLogWebhookFailure('sentinel', 'server_not_functional', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Server is not functional'], 401);
}
if ($server->settings->sentinel_token !== $naked_token) {
auditLogWebhookFailure('sentinel', 'token_mismatch', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
$data = $request->all();
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
$server->sentinelHeartbeat();
if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data);
}
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'ok'], 200);
}
/**
* Decide whether PushServerUpdateJob should be dispatched for this push.
*
* Dispatches when: first push (no cached hash), the container state changed,
* or the force window elapsed.
*/
private function shouldDispatchUpdate(Server $server, array $data): bool
{
$hash = $this->containerStateHash($data);
$hashKey = "sentinel:push-hash:{$server->id}";
$forceKey = "sentinel:push-force:{$server->id}";
$cachedHash = Cache::get($hashKey);
$forceActive = Cache::has($forceKey);
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
if ($shouldDispatch) {
// Day-long TTL bounds memory if a server stops pushing entirely.
Cache::put($hashKey, $hash, now()->addDay());
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
}
return $shouldDispatch;
}
/**
* Build a stable hash of container state.
*
* Covers [name, state, health_status] only metrics and
* filesystem_usage_root are excluded on purpose (disk % churns constantly
* and would defeat the hash; the storage check is separately cache-gated
* inside PushServerUpdateJob). Sorted by name so container ordering from
* Sentinel does not affect the hash.
*/
private function containerStateHash(array $data): string
{
$containers = collect(data_get($data, 'containers', []))
->map(fn ($c) => [
'name' => data_get($c, 'name'),
'state' => data_get($c, 'state'),
'health_status' => data_get($c, 'health_status'),
])
->sortBy('name')
->values()
->all();
return hash('xxh128', json_encode($containers));
}
}
+13 -17
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -14,6 +15,7 @@ use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -62,8 +64,14 @@ class Bitbucket extends Controller
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$applications = $applications->where('git_branch', $branch)->get();
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
}
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response([
'status' => 'failed',
@@ -79,11 +87,7 @@ class Bitbucket extends Controller
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -97,11 +101,7 @@ class Bitbucket extends Controller
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -114,11 +114,7 @@ class Bitbucket extends Controller
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
use App\Models\Application;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
trait MatchesManualWebhookApplications
{
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
{
if (! is_string($fullName)) {
return null;
}
$fullName = trim($fullName, " \t\n\r\0\x0B/");
if ($fullName === '') {
return null;
}
if (! preg_match('/\A[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\z/', $fullName)) {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($fullName);
}
/**
* @return Collection<int, Application>
*/
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
{
return $query->get()
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
->values();
}
protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
{
$repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
if ($repositoryPath === null) {
return false;
}
// Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
// case-insensitively, so compare the canonical paths case-insensitively.
return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
}
/**
* @return array{status: string, message: string}
*/
protected function unauthenticatedManualWebhookFailurePayload(): array
{
return [
'status' => 'failed',
'message' => 'Invalid signature.',
];
}
protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
{
if (! is_string($gitRepository)) {
return null;
}
$gitRepository = trim($gitRepository);
if ($gitRepository === '') {
return null;
}
$path = null;
$parts = parse_url($gitRepository);
if (is_array($parts) && isset($parts['scheme'])) {
$path = data_get($parts, 'path');
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
$path = Str::after($gitRepository, ':');
} else {
$path = $gitRepository;
}
if (! is_string($path) || $path === '') {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($path);
}
protected function normalizeManualWebhookRepositoryPath(string $path): string
{
$path = trim($path);
$path = strtok($path, '?#') ?: $path;
$path = trim($path, '/');
$path = preg_replace('/\.git\z/i', '', $path) ?? $path;
return $path;
}
}
+11 -13
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -15,6 +16,7 @@ use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -58,15 +60,19 @@ class Gitea extends Controller
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_gitea_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_gitea_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
@@ -80,11 +86,7 @@ class Gitea extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -96,11 +98,7 @@ class Gitea extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
+135 -53
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
@@ -11,6 +12,7 @@ use App\Models\GithubApp;
use App\Models\PrivateKey;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@@ -18,6 +20,7 @@ use Visus\Cuid2\Cuid2;
class Github extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -66,15 +69,19 @@ class Github extends Controller
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
@@ -93,11 +100,7 @@ class Github extends Controller
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -109,11 +112,7 @@ class Github extends Controller
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -454,53 +453,136 @@ class Github extends Controller
public function redirect(Request $request)
{
try {
$code = $request->get('code');
$state = $request->get('state');
$github_app = GithubApp::where('uuid', $state)->firstOrFail();
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
$code = (string) $request->query('code', '');
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (Exception $e) {
return handleError($e);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'manifest',
);
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)
->accept('application/vnd.github+json')
->timeout(10)
->connectTimeout(5)
->post("$api_url/app-manifests/$code/conversions")
->throw()
->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
public function install(Request $request)
{
try {
$installation_id = $request->get('installation_id');
$source = $request->get('source');
$setup_action = $request->get('setup_action');
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
if ($setup_action === 'install') {
$github_app->installation_id = $installation_id;
$github_app->save();
}
$source = (string) $request->query('source', '');
abort_if(blank($source), 404);
$github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail();
$setup_action = (string) $request->query('setup_action', '');
if ($setup_action !== 'install') {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (Exception $e) {
return handleError($e);
}
$installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
abort_unless(
$this->githubInstallationBelongsToApp($github_app, $installation_id),
403,
'GitHub App installation could not be verified.'
);
$github_app->installation_id = $installation_id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
/**
* Verify that the given installation id actually belongs to this GitHub App.
*
* The installation id arrives as an untrusted query parameter on an
* unauthenticated-reachable GET callback, so it must be confirmed against
* the GitHub API using the App's own credentials before it is persisted.
*/
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
{
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
return false;
}
try {
$jwt = generateGithubJwt($github_app);
$response = Http::withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->connectTimeout(5)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
return $response->successful()
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
} catch (\Throwable) {
return false;
}
}
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{
abort_if(blank($state), 404);
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
abort_unless(is_array($payload), 404);
abort_unless(data_get($payload, 'action') === $action, 404);
$team_id = $request->user()?->currentTeam()?->id;
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', data_get($payload, 'team_id'))
->firstOrFail();
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
{
return filled($github_app->app_id)
|| filled($github_app->client_id)
|| filled($github_app->client_secret)
|| filled($github_app->webhook_secret)
|| filled($github_app->private_key_id);
}
}
+16 -13
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -15,6 +16,7 @@ use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -85,9 +87,18 @@ class Gitlab extends Controller
return response($return_payloads);
}
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
return response($return_payloads);
}
$applications = Application::query();
if ($x_gitlab_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -98,7 +109,7 @@ class Gitlab extends Controller
}
}
if ($x_gitlab_event === 'merge_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -117,11 +128,7 @@ class Gitlab extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -132,11 +139,7 @@ class Gitlab extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
+1 -1
View File
@@ -197,7 +197,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public int $application_deployment_queue_id)
{
$this->onQueue('high');
$this->onQueue(deployment_queue());
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
$this->nixpacks_plan_json = collect([]);
+218 -5
View File
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
@@ -20,6 +21,199 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
{
$this->cleanupStaleConnections();
$this->cleanupNonExistentServerConnections();
$this->cleanupDuplicateSshProcesses();
$this->cleanupOrphanedSshProcesses();
$this->cleanupOrphanedCloudflaredProcesses();
}
/**
* Once two background ssh masters share the same ControlPath, OpenSSH's
* control socket state is no longer trustworthy: `ssh -O check` may report
* one PID while the socket lifecycle is tied to another. Reset the whole
* duplicate group rather than trying to choose an owner.
*/
private function cleanupDuplicateSshProcesses(): void
{
$muxDir = storage_path('app/ssh/mux');
$groups = [];
foreach ($this->listProcesses() as $process) {
$controlPath = $this->extractControlPath($process['args']);
if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) {
continue;
}
$groups[$controlPath][] = $process;
}
foreach ($groups as $controlPath => $processes) {
if (count($processes) < 2) {
continue;
}
$this->resetDuplicateGroup($controlPath, $processes);
}
}
/**
* Kill backgrounded ssh master processes that lost the ControlPath socket
* race. Such processes are not masters, so ControlPersist never reaps them
* and they leak memory until the container restarts. A legitimate master
* always owns its socket file; an orphan has none.
*
* Processes younger than the minimum age are skipped: a freshly forked
* master creates its socket a few milliseconds after starting, so a young
* process with no socket may simply be mid-establish rather than orphaned.
*/
private function cleanupOrphanedSshProcesses(): void
{
$muxDir = storage_path('app/ssh/mux');
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
foreach ($this->listProcesses() as $process) {
// Only ever touch ssh processes pointing at Coolify's mux directory.
$controlPath = $this->extractControlPath($process['args']);
if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) {
continue;
}
if ($process['etimes'] >= $minAge && ! file_exists($controlPath)) {
$this->reapOrphan('ssh', $process);
}
}
}
/**
* Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
* as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
* die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
* mux master), the cloudflared process can leak and accumulate. A legitimate
* proxy always has a live ssh parent; one without is safe to reap.
*
* Processes younger than the minimum age are skipped so a proxy whose parent
* ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
* never mistaken for an orphan.
*/
private function cleanupOrphanedCloudflaredProcesses(): void
{
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
$processes = $this->listProcesses();
$sshPids = [];
foreach ($processes as $process) {
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
$sshPids[$process['pid']] = true;
}
}
foreach ($processes as $process) {
// `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
if (! str_contains($process['args'], 'cloudflared access ssh')) {
continue;
}
// Orphaned when no live ssh process is its parent.
if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
$this->reapOrphan('cloudflared', $process);
}
}
}
/**
* Reap a detected orphan process. When orphan reaping is disabled (the
* default), the orphan is only logged a dry-run mode that lets operators
* verify what would be killed before enabling it for real.
*
* @param array{pid: string, ppid: string, etimes: int, args: string} $process
*/
private function reapOrphan(string $kind, array $process): void
{
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
'pid' => $process['pid'],
'etimes' => $process['etimes'],
'command' => $process['args'],
]);
return;
}
Process::run('kill '.escapeshellarg($process['pid']));
Log::info("Killed orphaned {$kind} process", [
'pid' => $process['pid'],
'etimes' => $process['etimes'],
'command' => $process['args'],
]);
}
/**
* Snapshot of running processes.
*
* @return list<array{pid: string, ppid: string, etimes: int, args: string}>
*/
private function listProcesses(): array
{
$ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
if ($ps->exitCode() !== 0) {
return [];
}
$processes = [];
foreach (explode("\n", trim($ps->output())) as $line) {
if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
continue;
}
$processes[] = [
'pid' => $matches[1],
'ppid' => $matches[2],
'etimes' => (int) $matches[3],
'args' => $matches[4],
];
}
return $processes;
}
/**
* @param list<array{pid: string, ppid: string, etimes: int, args: string}> $processes
*/
private function resetDuplicateGroup(string $controlPath, array $processes): void
{
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
Log::info('Duplicate ssh mux processes detected (dry-run, not killed)', [
'control_path' => $controlPath,
'pids' => array_column($processes, 'pid'),
]);
return;
}
foreach ($processes as $process) {
Process::run('kill '.escapeshellarg($process['pid']));
}
if (file_exists($controlPath)) {
@unlink($controlPath);
}
Log::info('Reset duplicate ssh mux processes', [
'control_path' => $controlPath,
'pids' => array_column($processes, 'pid'),
]);
}
private function extractControlPath(string $args): ?string
{
if (! preg_match('/(?:^|\s)-o\s+ControlPath=(?:"([^"]+)"|\'([^\']+)\'|(\S+))/', $args, $matches)) {
if (preg_match('/^ssh:\s+(\S+)\s+\[mux\]$/', $args, $matches)) {
return $matches[1];
}
return null;
}
return $matches[1] ?: ($matches[2] ?: $matches[3]);
}
private function cleanupStaleConnections()
@@ -31,7 +225,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$server = Server::where('uuid', $serverUuid)->first();
if (! $server) {
$this->removeMultiplexFile($muxFile);
$this->removeMultiplexFile($muxFile, 'server_not_found');
continue;
}
@@ -41,14 +235,14 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) {
$this->removeMultiplexFile($muxFile);
$this->removeMultiplexFile($muxFile, 'connection_check_failed');
} else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) {
$this->removeMultiplexFile($muxFile);
$this->removeMultiplexFile($muxFile, 'expired');
}
}
}
@@ -62,7 +256,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
if (! in_array($serverUuid, $existingServerUuids)) {
$this->removeMultiplexFile($muxFile);
$this->removeMultiplexFile($muxFile, 'server_does_not_exist');
}
}
}
@@ -72,11 +266,30 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
return substr($muxFile, 4);
}
private function removeMultiplexFile($muxFile)
/**
* Close and delete a stale mux socket file. When orphan reaping is disabled
* (the default), the file is only logged a dry-run mode that lets operators
* verify what would be removed before enabling it for real.
*/
private function removeMultiplexFile(string $muxFile, string $reason): void
{
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
Log::info('Stale mux file detected (dry-run, not removed)', [
'file' => $muxFile,
'reason' => $reason,
]);
return;
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFile);
Log::info('Removed stale mux file', [
'file' => $muxFile,
'reason' => $reason,
]);
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600;
}
+11 -6
View File
@@ -127,15 +127,20 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
$data = collect($this->data);
$this->server->sentinelHeartbeat();
// Heartbeat is updated by SentinelController on every push, before dispatch.
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
// Only dispatch storage check when disk percentage actually changes
// Only dispatch the storage check when disk usage is at/above the notification
// threshold AND the value changed. Below the threshold ServerStorageCheckJob
// has nothing to do (it only sends a HighDiskUsage notification), so dispatching
// it is wasted work — and most servers sit well below the threshold.
$diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80);
$storageCacheKey = 'storage-check:'.$this->server->id;
$lastPercentage = Cache::get($storageCacheKey);
if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) {
if ($filesystemUsageRoot !== null
&& $filesystemUsageRoot >= $diskThreshold
&& (string) $lastPercentage !== (string) $filesystemUsageRoot) {
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
}
@@ -500,11 +505,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
} catch (\Throwable $e) {
}
} else {
// Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches.
// Connect proxy to networks periodically as a safety net to avoid excessive job dispatches.
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
if (! Cache::has($proxyCacheKey)) {
Cache::put($proxyCacheKey, true, 600);
Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600));
ConnectProxyToNetworksJob::dispatch($this->server);
}
}
+1 -11
View File
@@ -37,17 +37,7 @@ class ScheduledJobManager implements ShouldQueue
*/
public function __construct()
{
$this->onQueue($this->determineQueue());
}
private function determineQueue(): string
{
$preferredQueue = 'crons';
$fallbackQueue = 'high';
$configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
$this->onQueue(crons_queue());
}
/**
+1 -1
View File
@@ -65,7 +65,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
public function __construct($task)
{
$this->onQueue('high');
$this->onQueue(crons_queue());
$this->task = $task;
if ($service = $task->service()->first()) {
+9 -3
View File
@@ -3,6 +3,8 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
@@ -21,7 +23,7 @@ class Source extends Component
#[Validate(['nullable', 'string'])]
public ?string $privateKeyName = null;
#[Validate(['nullable', 'integer'])]
#[Locked]
public ?int $privateKeyId = null;
#[Validate(['required', 'string'])]
@@ -103,7 +105,8 @@ class Source extends Component
{
try {
$this->authorize('update', $this->application);
$this->privateKeyId = $privateKeyId;
$key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
$this->privateKeyId = $key->id;
$this->syncData(true);
$this->getPrivateKeys();
$this->application->refresh();
@@ -136,8 +139,11 @@ class Source extends Component
try {
$this->authorize('update', $this->application);
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
$source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
$this->application->update([
'source_id' => $sourceId,
'source_id' => $source->id,
'source_type' => $sourceType,
]);
+10 -4
View File
@@ -301,10 +301,16 @@ EOD;
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$service = Service::whereUuid($serviceUuid)->first();
if (! $service) {
abort(404);
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($this->parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($this->parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
+5 -7
View File
@@ -4,12 +4,14 @@ namespace App\Livewire\Project;
use App\Models\Environment;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
class DeleteEnvironment extends Component
{
use AuthorizesRequests;
#[Locked]
public int $environment_id;
public bool $disabled = false;
@@ -20,12 +22,8 @@ class DeleteEnvironment extends Component
public function mount()
{
try {
$this->environmentName = Environment::findOrFail($this->environment_id)->name;
$this->parameters = get_route_parameters();
} catch (\Exception $e) {
return handleError($e, $this);
}
$this->parameters = get_route_parameters();
$this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name;
}
public function delete()
@@ -33,7 +31,7 @@ class DeleteEnvironment extends Component
$this->validate([
'environment_id' => 'required|int',
]);
$environment = Environment::findOrFail($this->environment_id);
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
if ($environment->isEmpty()) {
@@ -9,6 +9,7 @@ use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Locked;
use Livewire\Component;
class GithubPrivateRepository extends Component
@@ -29,6 +30,7 @@ class GithubPrivateRepository extends Component
public int $selected_repository_id;
#[Locked]
public int $selected_github_app_id;
public string $selected_repository_owner;
@@ -37,8 +39,6 @@ class GithubPrivateRepository extends Component
public string $selected_branch_name = 'main';
public string $token;
public $repositories;
public int $total_repositories_count = 0;
@@ -71,7 +71,10 @@ class GithubPrivateRepository extends Component
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->repositories = $this->branches = collect();
$this->github_apps = GithubApp::private();
$this->github_apps = GithubApp::where('team_id', currentTeam()->id)
->where('is_public', false)
->whereNotNull('app_id')
->get();
}
public function updatedSelectedRepositoryId(): void
@@ -96,22 +99,25 @@ class GithubPrivateRepository extends Component
}
}
public function loadRepositories($github_app_id)
public function loadRepositories(int $github_app_id): void
{
$this->repositories = collect();
$this->branches = collect();
$this->total_branches_count = 0;
$this->page = 1;
$this->selected_github_app_id = $github_app_id;
$this->github_app = GithubApp::where('id', $github_app_id)->first();
$this->token = generateGithubInstallationToken($this->github_app);
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
$this->github_app = GithubApp::where('team_id', currentTeam()->id)
->where('is_public', false)
->whereNotNull('app_id')
->findOrFail($github_app_id);
$token = generateGithubInstallationToken($this->github_app);
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
if ($this->repositories->count() < $this->total_repositories_count) {
while ($this->repositories->count() < $this->total_repositories_count) {
$this->page++;
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
}
@@ -142,7 +148,9 @@ class GithubPrivateRepository extends Component
protected function loadBranchByPage()
{
$response = Http::GitHub($this->github_app->api_url, $this->token)
$token = generateGithubInstallationToken($this->github_app);
$response = Http::GitHub($this->github_app->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
@@ -28,10 +28,16 @@ class DatabaseBackups extends Component
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
+30
View File
@@ -7,12 +7,15 @@ use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Enums\ProcessStatus;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
use AuthorizesRequests;
public Service $service;
public array $parameters;
@@ -27,6 +30,8 @@ class Heading extends Component
public function mount()
{
$this->authorizeService('view');
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged');
@@ -47,6 +52,8 @@ class Heading extends Component
public function checkStatus()
{
$this->authorizeService('view');
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
} else {
@@ -61,6 +68,8 @@ class Heading extends Component
public function serviceChecked()
{
$this->authorizeService('view');
try {
$this->service->applications->each(function ($application) {
$application->refresh();
@@ -82,6 +91,8 @@ class Heading extends Component
public function checkDeployments()
{
$this->authorizeService('view');
try {
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
@@ -99,12 +110,16 @@ class Heading extends Component
public function start()
{
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id);
}
public function forceDeploy()
{
$this->authorizeService('deploy');
try {
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
@@ -124,6 +139,8 @@ class Heading extends Component
public function stop()
{
$this->authorizeService('stop');
try {
StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) {
@@ -133,6 +150,8 @@ class Heading extends Component
public function restart()
{
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -145,6 +164,8 @@ class Heading extends Component
public function pullAndRestartEvent()
{
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -155,6 +176,15 @@ class Heading extends Component
$this->dispatch('activityMonitor', $activity->id);
}
private function authorizeService(string $ability): void
{
$this->service = Service::ownedByCurrentTeam()
->whereKey($this->service->getKey())
->firstOrFail();
$this->authorize($ability, $this->service);
}
public function render()
{
return view('livewire.project.service.heading', [
+10 -4
View File
@@ -108,10 +108,16 @@ class Index extends Component
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
+27 -11
View File
@@ -110,15 +110,23 @@ class Destination extends Component
public function promote(int $network_id, int $server_id)
{
$main_destination = $this->resource->destination;
$this->resource->update([
'destination_id' => $network_id,
'destination_type' => StandaloneDocker::class,
]);
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
$this->refreshServers();
$this->resource->refresh();
try {
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_id);
$this->authorize('update', $this->resource);
$main_destination = $this->resource->destination;
$this->resource->update([
'destination_id' => $network->id,
'destination_type' => StandaloneDocker::class,
]);
$this->resource->additional_networks()->detach($network->id, ['server_id' => $server->id]);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
$this->refreshServers();
$this->resource->refresh();
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function refreshServers()
@@ -130,8 +138,16 @@ class Destination extends Component
public function addServer(int $network_id, int $server_id)
{
$this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
$this->dispatch('refresh');
try {
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_id);
$this->authorize('update', $this->resource);
$this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]);
$this->dispatch('refresh');
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
+8 -5
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Security;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ApiTokens extends Component
@@ -29,8 +30,10 @@ class ApiTokens extends Component
public $isApiEnabled;
#[Locked]
public bool $canUseRootPermissions = false;
#[Locked]
public bool $canUseWritePermissions = false;
public function render()
@@ -54,7 +57,7 @@ class ApiTokens extends Component
public function updatedPermissions($permissionToUpdate)
{
// Check if user is trying to use restricted permissions
if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) {
if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use root permissions.');
// Remove root from permissions if it was somehow added
$this->permissions = array_diff($this->permissions, ['root']);
@@ -62,7 +65,7 @@ class ApiTokens extends Component
return;
}
if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) {
if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use write permissions.');
// Remove write permissions if they were somehow added
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
@@ -72,7 +75,7 @@ class ApiTokens extends Component
if ($permissionToUpdate == 'root') {
$this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) {
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
$this->permissions[] = 'read';
} elseif ($permissionToUpdate == 'deploy') {
$this->permissions = ['deploy'];
@@ -90,11 +93,11 @@ class ApiTokens extends Component
$this->authorize('create', PersonalAccessToken::class);
// Validate permissions based on user role
if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) {
if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.');
}
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) {
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.');
}
+23
View File
@@ -7,7 +7,9 @@ use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
@@ -72,6 +74,8 @@ class Change extends Component
public $privateKeys;
public string $manifestState = '';
protected function rules(): array
{
return [
@@ -147,6 +151,24 @@ class Change extends Component
}
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function createGithubAppSetupState(string $action): string
{
$state = Str::random(64);
Cache::put($this->githubAppSetupStateCacheKey($state), [
'action' => $action,
'github_app_id' => $this->github_app->id,
'team_id' => $this->github_app->team_id,
], now()->addMinutes(60));
return $state;
}
public function checkPermissions()
{
try {
@@ -211,6 +233,7 @@ class Change extends Component
// Override name with kebab case for display
$this->name = str($this->github_app->name)->kebab();
$this->fqdn = $settings->fqdn;
$this->manifestState = $this->createGithubAppSetupState('manifest');
if ($settings->public_ipv4) {
$this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
-20
View File
@@ -73,26 +73,6 @@ class GithubApp extends BaseModel
});
}
public static function public()
{
return GithubApp::where(function ($query) {
$query->where(function ($q) {
$q->where('team_id', currentTeam()->id)
->orWhere('is_system_wide', true);
})->where('is_public', true);
})->whereNotNull('app_id')->get();
}
public static function private()
{
return GithubApp::where(function ($query) {
$query->where(function ($q) {
$q->where('team_id', currentTeam()->id)
->orWhere('is_system_wide', true);
})->where('is_public', false);
})->whereNotNull('app_id')->get();
}
public function team()
{
return $this->belongsTo(Team::class);
+33
View File
@@ -592,6 +592,39 @@ function isCloud(): bool
return ! config('constants.coolify.self_hosted');
}
/**
* Resolve the queue used for application deployments, database starts and service starts.
*
* On cloud these jobs run on a dedicated `deployments` queue so they can be drained by an
* isolated Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing
* is decided by `isCloud()` (config-based) rather than `HORIZON_QUEUES`, so the dispatching
* process needs no special env only the worker must be configured to drain `deployments`.
*
* IMPORTANT: on cloud a worker MUST include `deployments` in its `HORIZON_QUEUES`, otherwise
* these jobs are never processed.
*/
function deployment_queue(): string
{
return isCloud() ? 'deployments' : 'high';
}
/**
* Resolve the queue used for scheduled jobs the scheduler dispatcher, scheduled tasks and
* scheduled database backups, whether triggered automatically or manually.
*
* On cloud these jobs run on a dedicated `crons` queue so they can be drained by an isolated
* Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing is decided
* by `isCloud()` (config-based), so the dispatching process needs no special env only the
* worker must be configured to drain `crons`.
*
* IMPORTANT: on cloud a worker MUST include `crons` in its `HORIZON_QUEUES`, otherwise these
* jobs are never processed.
*/
function crons_queue(): string
{
return isCloud() ? 'crons' : 'high';
}
function translate_cron_expression($expression_to_validate): string
{
if (isset(VALID_CRON_STRINGS[$expression_to_validate])) {
Generated
+1641 -880
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.1.0',
'version' => '4.1.1',
'helper_version' => '1.0.14',
'realtime_version' => '1.0.15',
'railpack_version' => '0.23.0',
@@ -16,7 +16,7 @@ return [
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'),
],
'urls' => [
@@ -70,6 +70,10 @@ return [
'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true),
'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5),
'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes
'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds
'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds
'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds
'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans
'connection_timeout' => 10,
'server_interval' => 20,
'command_timeout' => 3600,
@@ -94,6 +98,21 @@ return [
'sentry_dsn' => env('SENTRY_DSN'),
],
'sentinel' => [
// How often (seconds) PushServerUpdateJob is force-dispatched even when
// the container state hash is unchanged. Keeps last_online_at,
// exited-detection and storage checks from going stale.
'push_force_interval_seconds' => env('SENTINEL_PUSH_FORCE_INTERVAL_SECONDS', 300),
],
'proxy' => [
// How often (seconds) PushServerUpdateJob periodically re-connects the
// proxy to Docker networks as a safety net. Real network-layout changes
// already connect the proxy on-demand; this only covers gaps (Swarm
// networks added via UI, proxy crash recovery).
'connect_networks_interval_seconds' => env('PROXY_CONNECT_NETWORKS_INTERVAL_SECONDS', 3600),
],
'webhooks' => [
'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'),
'dev_webhook' => env('SERVEO_URL'),
+41 -17
View File
@@ -1,6 +1,46 @@
<?php
use Illuminate\Support\Str;
use Pdo\Pgsql;
$pgsql = [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'coolify-db'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'coolify'),
'username' => env('DB_USERNAME', 'coolify'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'options' => [
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
],
];
/*
* Opt-in read/write replica split. Activates only when DB_READ_HOST is set.
* When unset, the pgsql connection is identical to a single-primary setup.
* Hosts may be comma-separated; Laravel random-picks one per connection.
*/
if (env('DB_READ_HOST')) {
$pgsql['read'] = [
'host' => array_map('trim', explode(',', (string) env('DB_READ_HOST'))),
'port' => env('DB_READ_PORT', env('DB_PORT', '5432')),
'username' => env('DB_READ_USERNAME', env('DB_USERNAME', 'coolify')),
'password' => env('DB_READ_PASSWORD', env('DB_PASSWORD', '')),
];
$pgsql['write'] = [
'host' => array_map('trim', explode(',', (string) env('DB_WRITE_HOST', env('DB_HOST', 'coolify-db')))),
'port' => env('DB_WRITE_PORT', env('DB_PORT', '5432')),
'username' => env('DB_WRITE_USERNAME', env('DB_USERNAME', 'coolify')),
'password' => env('DB_WRITE_PASSWORD', env('DB_PASSWORD', '')),
];
$pgsql['sticky'] = (bool) env('DB_STICKY', true);
}
return [
@@ -35,23 +75,7 @@ return [
'connections' => [
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'coolify-db'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'coolify'),
'username' => env('DB_USERNAME', 'coolify'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'options' => [
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
],
],
'pgsql' => $pgsql,
'testing' => [
'driver' => 'sqlite',
+2 -2
View File
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.1.0"
"version": "4.1.1"
},
"nightly": {
"version": "4.0.0"
"version": "4.2.0"
},
"helper": {
"version": "1.0.14"
+1 -478
View File
@@ -10,20 +10,15 @@
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"ioredis": "5.6.1",
"playwright": "^1.58.2"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@vitejs/plugin-vue": "6.0.3",
"laravel-echo": "2.2.7",
"laravel-vite-plugin": "2.0.1",
"postcss": "8.5.6",
"pusher-js": "8.4.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18",
"vite": "7.3.2",
"vue": "3.5.26"
"vite": "7.3.2"
}
},
"node_modules/@alloc/quick-lru": {
@@ -39,56 +34,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -531,12 +476,6 @@
"node": ">=18"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -587,13 +526,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
"integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -944,14 +876,6 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
@@ -1324,132 +1248,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
"integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.53"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.26",
"entities": "^7.0.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26",
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.26",
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.26",
"@vue/runtime-core": "3.5.26",
"@vue/shared": "3.5.26",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26"
},
"peerDependencies": {
"vue": "3.5.26"
}
},
"node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
@@ -1475,15 +1273,6 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1496,39 +1285,6 @@
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1539,32 +1295,6 @@
"node": ">=8"
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@@ -1579,19 +1309,6 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -1634,13 +1351,6 @@
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1681,30 +1391,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
"integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1715,20 +1401,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/laravel-echo": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.7.tgz",
"integrity": "sha512-MgD3ZFXqH5OOVdRjxNHPyQ0ijRr5+nLr7MtyF2XP+kRfhl+Qaa7qVzbtCn1HMgXuTn4SWH6ivn4qWVLlvRl8kg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"pusher-js": "*",
"socket.io-client": "*"
}
},
"node_modules/laravel-vite-plugin": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
@@ -2016,18 +1688,6 @@
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -2059,12 +1719,6 @@
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2204,16 +1858,6 @@
"react": ">=16.0.0"
}
},
"node_modules/pusher-js": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -2225,27 +1869,6 @@
"node": ">=0.10.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2291,38 +1914,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2333,12 +1924,6 @@
"node": ">=0.10.0"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/tailwind-scrollbar": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz",
@@ -2392,13 +1977,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true,
"license": "Unlicense"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -2503,61 +2081,6 @@
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vue": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
"@vue/runtime-dom": "3.5.26",
"@vue/server-renderer": "3.5.26",
"@vue/shared": "3.5.26"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}
}
}
}
+1 -6
View File
@@ -8,22 +8,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@vitejs/plugin-vue": "6.0.3",
"laravel-echo": "2.2.7",
"laravel-vite-plugin": "2.0.1",
"postcss": "8.5.6",
"pusher-js": "8.4.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18",
"vite": "7.3.2",
"vue": "3.5.26"
"vite": "7.3.2"
},
"dependencies": {
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"ioredis": "5.6.1",
"playwright": "^1.58.2"
}
}
+2 -2
View File
File diff suppressed because one or more lines are too long
+3 -4
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -172,7 +172,8 @@
}
@auth
window.Pusher = Pusher;
window.Echo = new Echo({
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
window.Echo = new EchoConstructor({
broadcaster: 'pusher',
cluster: "{{ config('constants.pusher.host') }}" || window.location.hostname,
key: "{{ config('constants.pusher.app_key') }}" || 'coolify',
@@ -9,8 +9,11 @@
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
rafId: null,
scrollTimeout: null,
scrollDebounce: null,
isScrolling: false,
destroyed: false,
deploymentFinishedCleanup: null,
lastTouchY: 0,
showTimestamps: true,
searchQuery: '',
@@ -20,20 +23,28 @@
this.fullscreen = !this.fullscreen;
},
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (this.destroyed) return;
const logsContainer = this.$root.querySelector('#logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
requestAnimationFrame(() => { this.isScrolling = false; });
}
},
cancelScrollLoop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = null;
}
},
disableFollow() {
if (!this.alwaysScroll) return;
this.alwaysScroll = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.cancelScrollLoop();
},
handleWheel(event) {
if (this.alwaysScroll && event.deltaY < 0) {
@@ -59,10 +70,11 @@
}
},
handleScroll(event) {
if (this.isScrolling) return;
if (this.isScrolling || this.destroyed) return;
const el = event.target;
clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => {
const el = event.target;
if (this.destroyed) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (!this.alwaysScroll && distanceFromBottom <= 10) {
this.alwaysScroll = true;
@@ -71,11 +83,12 @@
}, 150);
},
scheduleScroll() {
if (!this.alwaysScroll) return;
if (!this.alwaysScroll || this.destroyed) return;
this.rafId = requestAnimationFrame(() => {
if (!this.alwaysScroll || this.destroyed) return;
this.scrollToBottom();
if (this.alwaysScroll) {
setTimeout(() => this.scheduleScroll(), 250);
if (this.alwaysScroll && !this.destroyed) {
this.scrollTimeout = setTimeout(() => this.scheduleScroll(), 250);
}
});
},
@@ -84,10 +97,7 @@
if (this.alwaysScroll) {
this.scheduleScroll();
} else {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.cancelScrollLoop();
}
},
hasActiveLogSelection() {
@@ -189,10 +199,7 @@
stopScroll() {
this.scrollToBottom();
this.alwaysScroll = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.cancelScrollLoop();
},
init() {
// Watch search query changes
@@ -200,21 +207,28 @@
this.applySearch();
});
// Apply search after Livewire updates
// Apply search after Livewire updates.
// Livewire.hook() has no deregister API, so this callback survives
// wire:navigate. It is made harmless after teardown by the
// `destroyed` guard and by only reacting to DOM inside this root.
Livewire.hook('morph.updated', ({ el }) => {
if (el.id === 'logs') {
this.$nextTick(() => {
this.applySearch();
if (this.alwaysScroll) {
this.scrollToBottom();
}
});
}
if (this.destroyed) return;
if (el.id !== 'logs' || !this.$root.contains(el)) return;
this.$nextTick(() => {
if (this.destroyed) return;
this.applySearch();
if (this.alwaysScroll) {
this.scrollToBottom();
}
});
});
// Stop auto-scroll when deployment finishes
Livewire.on('deploymentFinished', () => {
// Stop auto-scroll when deployment finishes.
// Livewire.on() returns an unregister fn; keep it for destroy().
this.deploymentFinishedCleanup = Livewire.on('deploymentFinished', () => {
if (this.destroyed) return;
setTimeout(() => {
if (this.destroyed) return;
this.stopScroll();
}, 500);
});
@@ -223,6 +237,20 @@
if (this.alwaysScroll) {
this.scheduleScroll();
}
},
destroy() {
// Runs when Alpine tears the component down (wire:navigate away).
this.destroyed = true;
this.alwaysScroll = false;
this.cancelScrollLoop();
if (this.scrollDebounce) {
clearTimeout(this.scrollDebounce);
this.scrollDebounce = null;
}
if (typeof this.deploymentFinishedCleanup === 'function') {
this.deploymentFinishedCleanup();
this.deploymentFinishedCleanup = null;
}
}
}" class="flex flex-1 min-h-0 flex-col overflow-hidden">
<livewire:project.application.deployment-navbar
@@ -274,10 +274,13 @@
<div>({{ $pull_request }})</div>
@endif
@if ($streamLogs)
<x-loading wire:poll.2000ms='getLogs(true)' />
<x-loading />
@endif
</div>
@endif
@if ($streamLogs)
<div class="sr-only" wire:poll.2000ms="getLogs(true)" aria-hidden="true"></div>
@endif
<div x-show="expanded" {{ $collapsible ? 'x-collapse' : '' }}
:class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'"
:style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''">
@@ -290,8 +290,8 @@
function createGithubApp(webhook_endpoint, preview_deployment_permissions, administration) {
const {
organization,
uuid,
html_url
html_url,
uuid
} = @json($github_app);
if (!webhook_endpoint) {
alert('Please select a webhook endpoint.');
@@ -299,6 +299,7 @@
}
let baseUrl = webhook_endpoint;
const name = @js($name);
const manifestState = @js($manifestState);
const isDev = @js(config('app.env')) ===
'local';
const devWebhook = @js(config('constants.webhooks.dev_webhook'));
@@ -340,7 +341,7 @@
};
const form = document.createElement('form');
form.setAttribute('method', 'post');
form.setAttribute('action', `${html_url}/${path}?state=${uuid}`);
form.setAttribute('action', `${html_url}/${path}?state=${manifestState}`);
const input = document.createElement('input');
input.setAttribute('id', 'manifest');
input.setAttribute('name', 'manifest');
+2 -71
View File
@@ -11,12 +11,11 @@ use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
use App\Http\Controllers\Api\ScheduledTasksController;
use App\Http\Controllers\Api\SecurityController;
use App\Http\Controllers\Api\SentinelController;
use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServicesController;
use App\Http\Controllers\Api\TeamController;
use App\Http\Middleware\ApiAllowed;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use Illuminate\Support\Facades\Route;
Route::get('/health', [OtherController::class, 'healthcheck']);
@@ -209,75 +208,7 @@ Route::group([
Route::group([
'prefix' => 'v1',
], function () {
Route::post('/sentinel/push', function () {
$token = request()->header('Authorization');
if (! $token) {
auditLogWebhookFailure('sentinel', 'token_missing');
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
try {
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
auditLogWebhookFailure('sentinel', 'decrypt_failed');
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
if (! $server_uuid) {
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
return response()->json(['message' => 'Invalid token'], 401);
}
$server = Server::where('uuid', $server_uuid)->first();
if (! $server) {
auditLogWebhookFailure('sentinel', 'server_not_found', [
'server_uuid' => $server_uuid,
]);
return response()->json(['message' => 'Server not found'], 404);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
if ($server->isFunctional() === false) {
auditLogWebhookFailure('sentinel', 'server_not_functional', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Server is not functional'], 401);
}
if ($server->settings->sentinel_token !== $naked_token) {
auditLogWebhookFailure('sentinel', 'token_mismatch', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
$data = request()->all();
// \App\Jobs\ServerCheckNewJob::dispatch($server, $data);
PushServerUpdateJob::dispatch($server, $data);
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'ok'], 200);
});
Route::post('/sentinel/push', [SentinelController::class, 'push']);
});
Route::any('/{any}', function () {
+5 -2
View File
@@ -7,8 +7,11 @@ use App\Http\Controllers\Webhook\Gitlab;
use App\Http\Controllers\Webhook\Stripe;
use Illuminate\Support\Facades\Route;
Route::get('/source/github/redirect', [Github::class, 'redirect']);
Route::get('/source/github/install', [Github::class, 'install']);
Route::middleware(['web', 'auth', 'throttle:30,1'])->group(function () {
Route::get('/source/github/redirect', [Github::class, 'redirect']);
Route::get('/source/github/install', [Github::class, 'install']);
});
Route::post('/source/github/events', [Github::class, 'normal']);
Route::post('/source/github/events/manual', [Github::class, 'manual']);
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Attributes\Locked;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
});
test('api token permission flags are locked', function (string $property) {
$property = new ReflectionProperty(ApiTokens::class, $property);
expect($property->getAttributes(Locked::class))->not->toBeEmpty();
})->with([
'root permission flag' => 'canUseRootPermissions',
'write permission flag' => 'canUseWritePermissions',
]);
test('member cannot tamper with root permission flag', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('canUseRootPermissions', true);
})->throws(CannotUpdateLockedPropertyException::class);
test('member cannot create root token through tampered permissions payload', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'pwned-root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->call('addNewToken');
expect($member->tokens()->count())->toBe(0);
});
test('member can still create read token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'read-token')
->set('expiresInDays', 30)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
$token = $member->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->abilities)->toBe(['read']);
});
test('owner can create root token', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->call('addNewToken')
->assertHasNoErrors();
$token = $owner->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->abilities)->toBe(['root']);
});
@@ -0,0 +1,127 @@
<?php
use App\Livewire\Project\Application\Source;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
/**
* Create a PrivateKey without firing model events. The PrivateKey `saving`
* hook validates/fingerprints real key material and the `saved` hook writes
* to the filesystem neither is wanted in a unit test. Skipping events also
* skips BaseModel's uuid generation, so the uuid is set explicitly here (it
* is not in $fillable, so it cannot go through mass assignment).
*/
function makePrivateKey(string $name, string $material, string $fingerprint, int $teamId): PrivateKey
{
return PrivateKey::withoutEvents(function () use ($name, $material, $fingerprint, $teamId) {
$key = new PrivateKey([
'name' => $name,
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n{$material}\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => $fingerprint,
'team_id' => $teamId,
]);
$key->uuid = (string) new Cuid2;
$key->save();
return $key;
});
}
beforeEach(function () {
// handleError() turns a ModelNotFoundException into abort(404); rendering the 404
// page reads InstanceSettings::get(), which findOrFail(0)s. Seed the singleton row.
// `id` is not in $fillable, so it must be set outside of mass assignment.
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
// Team A — the attacker
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->teamA->members()->attach($this->userA->id, ['role' => 'owner']);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'private_key_id' => null,
'source_id' => null,
'source_type' => null,
]);
// Team B — the victim (holds the secrets we are trying to steal)
$this->teamB = Team::factory()->create();
$this->victimPrivateKey = makePrivateKey('victim-ssh-key', 'VICTIM_KEY_MATERIAL', 'victim-fingerprint', $this->teamB->id);
$this->victimGithubApp = GithubApp::create([
'name' => 'victim-github-app',
'team_id' => $this->teamB->id,
'private_key_id' => $this->victimPrivateKey->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('setPrivateKey rejects a PrivateKey owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('setPrivateKey', $this->victimPrivateKey->id);
$this->applicationA->refresh();
expect($this->applicationA->private_key_id)->not->toBe($this->victimPrivateKey->id);
expect($this->applicationA->private_key_id)->toBeNull();
});
test('setPrivateKey accepts a PrivateKey owned by the current team', function () {
$ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id);
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('setPrivateKey', $ownKey->id);
$this->applicationA->refresh();
expect($this->applicationA->private_key_id)->toBe($ownKey->id);
});
test('changeSource rejects a GithubApp owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('changeSource', $this->victimGithubApp->id, GithubApp::class);
$this->applicationA->refresh();
expect($this->applicationA->source_id)->not->toBe($this->victimGithubApp->id);
expect($this->applicationA->source_type)->not->toBe(GithubApp::class);
});
test('changeSource rejects an arbitrary class as source_type', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('changeSource', $this->victimGithubApp->id, Server::class);
$this->applicationA->refresh();
expect($this->applicationA->source_type)->not->toBe(Server::class);
});
test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () {
// Without #[Locked], an attacker could POST {"updates": {"privateKeyId": <foreign_id>},
// "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through
// Application::update(['private_key_id' => $this->privateKeyId]) — bypassing setPrivateKey()
// and its team-scoped lookup entirely. Locking the property closes that path at the wire layer.
Livewire::test(Source::class, ['application' => $this->applicationA])
->set('privateKeyId', $this->victimPrivateKey->id);
})->throws(CannotUpdateLockedPropertyException::class);
@@ -0,0 +1,124 @@
<?php
use App\Livewire\Project\Shared\Destination;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'name' => 'dest-a-'.fake()->unique()->word(),
'network' => 'coolify-a-'.fake()->unique()->word(),
]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
// A second usable destination on Team A's own server, used for positive-path tests.
$this->serverA2 = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA2 = StandaloneDocker::factory()->create([
'server_id' => $this->serverA2->id,
'name' => 'dest-a2-'.fake()->unique()->word(),
'network' => 'coolify-a2-'.fake()->unique()->word(),
]);
// Victim: Team B
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'name' => 'dest-b-'.fake()->unique()->word(),
'network' => 'coolify-b-'.fake()->unique()->word(),
]);
// Act as attacker (Team A)
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
test('cannot attach another team\'s server + network to own application', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
// handleError on ModelNotFoundException calls abort(404); pivot assertion is source of truth.
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
expect($this->applicationA->fresh()->additional_servers)->toHaveCount(0);
});
test('cannot attach own network paired with another team\'s server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('cannot attach another team\'s network paired with own server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverA2->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('can attach own team\'s server + network to own application', function () {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
$additional = $this->applicationA->fresh()->additional_networks;
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA2->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id);
});
});
describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
test('cannot promote another team\'s network as the application\'s main destination', function () {
$originalDestinationId = $this->applicationA->destination_id;
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
});
});
@@ -0,0 +1,74 @@
<?php
/*
* Verifies the opt-in read/write replica split in config/database.php.
* The config file is re-required under different putenv() states so the
* env() calls re-evaluate, then the resulting pgsql array shape is asserted.
*/
function loadDbConfig(): array
{
return require base_path('config/database.php');
}
afterEach(function () {
foreach ([
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
'DB_STICKY',
] as $key) {
putenv($key);
}
});
it('has no replica keys when DB_READ_HOST is unset', function () {
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql)
->not->toHaveKey('read')
->not->toHaveKey('write')
->not->toHaveKey('sticky')
->and($pgsql['driver'])->toBe('pgsql');
});
it('enables the read/write split when DB_READ_HOST is set', function () {
putenv('DB_READ_HOST=replica1, replica2');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql)
->toHaveKey('read')
->toHaveKey('write')
->and($pgsql['read']['host'])->toBe(['replica1', 'replica2'])
->and($pgsql['sticky'])->toBeTrue();
});
it('falls back to DB_* values for unset replica options', function () {
putenv('DB_READ_HOST=replica1');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['read']['port'])->toBe(env('DB_PORT', '5432'))
->and($pgsql['read']['username'])->toBe(env('DB_USERNAME', 'coolify'))
->and($pgsql['write']['host'])->toBe([env('DB_HOST', 'coolify-db')]);
});
it('respects discrete replica overrides', function () {
putenv('DB_READ_HOST=replica1');
putenv('DB_READ_PORT=6432');
putenv('DB_READ_USERNAME=reader');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['read']['port'])->toBe('6432')
->and($pgsql['read']['username'])->toBe('reader');
});
it('disables sticky reads when DB_STICKY is false', function () {
putenv('DB_READ_HOST=replica1');
putenv('DB_STICKY=false');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['sticky'])->toBeFalse();
});
@@ -0,0 +1,88 @@
<?php
use App\Livewire\Project\DeleteEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
// Current team
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
// Another team
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('mount cannot load DeleteEnvironment with environment from another team', function () {
Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentB->id]);
})->throws(ModelNotFoundException::class);
test('mount can load DeleteEnvironment with own team environment', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
expect($component->get('environmentName'))->toBe($this->environmentA->name);
});
test('environment_id is locked and cannot be reassigned from the client', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
try {
$component->set('environment_id', $this->environmentB->id);
$this->fail('Setting a #[Locked] property should have thrown.');
} catch (CannotUpdateLockedPropertyException) {
expect(true)->toBeTrue();
}
});
test('delete still removes an empty environment owned by the current team', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id])
->set('parameters', ['project_uuid' => $this->projectA->uuid]);
$component->call('delete');
expect(Environment::find($this->environmentA->id))->toBeNull();
});
test('delete cannot resolve a non-empty environment from another team', function () {
// The team-scoped lookup must stay in the delete() path so the
// "has defined resources" branch can never run for an environment
// outside the caller's team.
Application::factory()->create([
'environment_id' => $this->environmentB->id,
]);
$teamScopedLookup = fn () => Environment::ownedByCurrentTeam()
->findOrFail($this->environmentB->id);
expect($teamScopedLookup)->toThrow(ModelNotFoundException::class);
});
test('team scoped lookup permits own team environment', function () {
// Positive case so the cross-team check above cannot pass merely
// because the helper itself is broken.
$found = Environment::ownedByCurrentTeam()->findOrFail($this->environmentA->id);
expect($found->id)->toBe($this->environmentA->id);
});
+99
View File
@@ -0,0 +1,99 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
InstanceSettings::unguarded(function () {
InstanceSettings::query()->create([
'id' => 0,
'is_registration_enabled' => true,
]);
});
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
});
function showDeployment(string $status): TestResponse
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => test()->application->id,
'deployment_uuid' => 'deploy-scroll-'.$status,
'server_id' => test()->server->id,
'status' => $status,
'logs' => json_encode([[
'command' => null,
'output' => 'log line for '.$status,
'type' => 'stdout',
'timestamp' => now()->toISOString(),
'hidden' => false,
'batch' => 1,
'order' => 1,
]], JSON_THROW_ON_ERROR),
]);
return test()->get(route('project.application.deployment.show', [
'project_uuid' => test()->project->uuid,
'environment_uuid' => test()->environment->uuid,
'application_uuid' => test()->application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
]));
}
it('does not enable follow mode for a finished deployment', function () {
$response = showDeployment(ApplicationDeploymentStatus::FINISHED->value);
$response->assertSuccessful();
$response->assertSee('alwaysScroll: false', false);
$response->assertDontSee('alwaysScroll: true', false);
});
it('enables follow mode for an in-progress deployment', function () {
$response = showDeployment(ApplicationDeploymentStatus::IN_PROGRESS->value);
$response->assertSuccessful();
$response->assertSee('alwaysScroll: true', false);
});
it('scopes scroll teardown to the component so a stale loop cannot leak across deployments', function () {
$content = showDeployment(ApplicationDeploymentStatus::FINISHED->value)->getContent();
// Alpine destroy() tears the scroll loop down on wire:navigate away.
expect($content)->toContain('destroy()')
->toContain('cancelScrollLoop()')
// Container lookup is component-scoped, not a global getElementById.
->toContain("this.\$root.querySelector('#logsContainer')")
->not->toContain("document.getElementById('logsContainer')")
// morph.updated hook only acts on this component's own DOM.
->toContain('this.$root.contains(el)')
// Continuation timeout is tracked so it can be cancelled.
->toContain('scrollTimeout');
});
@@ -130,6 +130,20 @@ describe('GetLogs Livewire action validation', function () {
});
});
describe('GetLogs stream polling', function () {
test('streaming logs polls when log panel is not collapsible', function () {
Livewire::test(GetLogs::class, [
'server' => $this->server,
'resource' => $this->application,
'container' => 'coolify-sentinel',
'collapsible' => false,
])
->assertDontSeeHtml('wire:poll.2000ms="getLogs(true)"')
->call('toggleStreamLogs')
->assertSeeHtml('wire:poll.2000ms="getLogs(true)"');
});
});
describe('GetLogs container name injection payloads are blocked by validation', function () {
test('newline injection payload is rejected', function () {
// The exact PoC payload from the advisory
@@ -5,8 +5,10 @@ use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -64,6 +66,21 @@ function fakeGithubHttp(array $repositories): void
]);
}
function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
{
$rsaKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($rsaKey, $pemKey);
return PrivateKey::create([
'name' => 'Test Key '.$team->id,
'private_key' => $pemKey,
'team_id' => $team->id,
]);
}
describe('GitHub Private Repository Component', function () {
test('loadRepositories fetches and displays repositories', function () {
$repos = [
@@ -81,6 +98,73 @@ describe('GitHub Private Repository Component', function () {
->assertSet('selected_repository_id', 1);
});
test('loadRepositories rejects a github app owned by another team', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$victimGithubApp = GithubApp::create([
'name' => 'Victim GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 54321,
'installation_id' => 98765,
'client_id' => 'victim-client-id',
'client_secret' => 'victim-client-secret',
'webhook_secret' => 'victim-webhook-secret',
'private_key_id' => $victimPrivateKey->id,
'team_id' => $victimTeam->id,
'is_public' => false,
'is_system_wide' => false,
]);
Http::fake();
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $victimGithubApp->id)
)->toThrow(ModelNotFoundException::class);
Http::assertNothingSent();
});
test('loadRepositories does not mint tokens for another teams system wide github app', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$systemWideGithubApp = GithubApp::create([
'name' => 'System Wide GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 54321,
'installation_id' => 98765,
'client_id' => 'system-client-id',
'client_secret' => 'system-client-secret',
'webhook_secret' => 'system-webhook-secret',
'private_key_id' => $victimPrivateKey->id,
'team_id' => $victimTeam->id,
'is_public' => false,
'is_system_wide' => true,
]);
Http::fake();
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $systemWideGithubApp->id)
)->toThrow(ModelNotFoundException::class);
Http::assertNothingSent();
});
test('github installation token is not stored as public component state', function () {
expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse();
});
test('selected github app id cannot be tampered with from the client', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->set('selected_github_app_id', $this->githubApp->id);
})->throws(CannotUpdateLockedPropertyException::class);
test('loadRepositories can be called again to refresh the repository list', function () {
$initialRepos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Jobs\PullChangelog;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
/**
* Fake releases land in a month that no real release uses, so the generated
* changelog file never collides with committed changelogs.
*/
function fakeReleasesPayload(): array
{
return [
[
'tag_name' => 'v9.9.9',
'name' => 'Test Release',
'body' => 'Released notes here.',
'draft' => false,
'published_at' => '1999-01-15T00:00:00Z',
],
[
'tag_name' => 'v9.9.8-draft',
'name' => 'Draft Release',
'body' => 'Should be skipped.',
'draft' => true,
'published_at' => '1999-01-10T00:00:00Z',
],
];
}
afterEach(function () {
File::delete(base_path('changelogs/1999-01.json'));
});
test('releases_url config defaults to the GitHub raw source', function () {
expect(config('constants.coolify.releases_url'))
->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
});
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
Http::fake([
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
]);
(new PullChangelog)->handle();
Http::assertSent(fn ($request) => $request->url() === 'https://example.test/releases.json');
$path = base_path('changelogs/1999-01.json');
expect(File::exists($path))->toBeTrue();
$data = json_decode(File::get($path), true);
expect($data['entries'])->toHaveCount(1)
->and($data['entries'][0]['tag_name'])->toBe('v9.9.9');
});
test('PullChangelog skips draft releases', function () {
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
Http::fake([
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
]);
(new PullChangelog)->handle();
$data = json_decode(File::get(base_path('changelogs/1999-01.json')), true);
$tags = array_column($data['entries'], 'tag_name');
expect($tags)->not->toContain('v9.9.8-draft');
});
@@ -16,10 +16,29 @@ beforeEach(function () {
Cache::flush();
});
it('dispatches storage check when disk percentage changes', function () {
it('dispatches storage check when disk percentage changes above threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Default notification threshold is 80%.
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 85;
});
});
it('does not dispatch storage check when disk usage is below threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// 45% is well below the default 80% notification threshold — nothing to do.
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
@@ -28,21 +47,19 @@ it('dispatches storage check when disk percentage changes', function () {
$job = new PushServerUpdateJob($server, $data);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 45;
});
Queue::assertNotPushed(ServerStorageCheckJob::class);
});
it('does not dispatch storage check when disk percentage is unchanged', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Simulate a previous push that cached the percentage
Cache::put('storage-check:'.$server->id, 45, 600);
// Simulate a previous push that cached the percentage (above threshold).
Cache::put('storage-check:'.$server->id, 85, 600);
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $data);
@@ -55,19 +72,19 @@ it('dispatches storage check when disk percentage changes from cached value', fu
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Simulate a previous push that cached 45%
Cache::put('storage-check:'.$server->id, 45, 600);
// Simulate a previous push that cached 85% (above threshold).
Cache::put('storage-check:'.$server->id, 85, 600);
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 50],
'filesystem_usage_root' => ['used_percentage' => 90],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 50;
return $job->server->id === $server->id && $job->percentage === 90;
});
});
@@ -140,6 +157,36 @@ it('dispatches ConnectProxyToNetworksJob again after cache expires', function ()
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
});
it('respects the configured proxy connect interval', function () {
// Interval 0 → the connect-proxy gate key expires immediately, so every
// push re-dispatches without a manual Cache::forget. Proves the TTL is
// driven by config('constants.proxy.connect_networks_interval_seconds').
config(['constants.proxy.connect_networks_interval_seconds' => 0]);
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$data = [
'containers' => [
[
'name' => 'coolify-proxy',
'state' => 'running',
'health_status' => 'healthy',
'labels' => ['coolify.managed' => true],
],
],
'filesystem_usage_root' => ['used_percentage' => 10],
];
(new PushServerUpdateJob($server, $data))->handle();
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
Queue::fake();
(new PushServerUpdateJob($server, $data))->handle();
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
});
it('uses default queue for PushServerUpdateJob', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
+74
View File
@@ -0,0 +1,74 @@
<?php
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Service\StartService;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\ScheduledJobManager;
use App\Models\ScheduledDatabaseBackup;
describe('deployment_queue helper', function () {
test('uses the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect(deployment_queue())->toBe('high');
});
test('uses the deployments queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect(deployment_queue())->toBe('deployments');
});
});
describe('crons_queue helper', function () {
test('uses the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect(crons_queue())->toBe('high');
});
test('uses the crons queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect(crons_queue())->toBe('crons');
});
});
describe('start action job routing', function () {
test('routes to the deployments queue on cloud', function (string $actionClass) {
config(['constants.coolify.self_hosted' => false]);
expect($actionClass::makeJob()->queue)->toBe('deployments');
})->with([
StartDatabase::class,
StartDatabaseProxy::class,
StartService::class,
]);
test('routes to the high queue on self-hosted', function (string $actionClass) {
config(['constants.coolify.self_hosted' => true]);
expect($actionClass::makeJob()->queue)->toBe('high');
})->with([
StartDatabase::class,
StartDatabaseProxy::class,
StartService::class,
]);
});
describe('scheduled job routing', function () {
test('scheduled jobs use the crons queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect((new ScheduledJobManager)->queue)->toBe('crons');
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('crons');
});
test('scheduled jobs use the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect((new ScheduledJobManager)->queue)->toBe('high');
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('high');
});
});
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]));
});
it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server double dispatch', function () {
$schedule = app(Schedule::class);
$event = collect($schedule->events())->first(
fn ($e) => str_contains((string) $e->description, 'RegenerateSslCertJob')
);
expect($event)->not->toBeNull();
expect($event->onOneServer)->toBeTrue();
});
it('schedules every production job with onOneServer', function () {
$schedule = app(Schedule::class);
$jobEvents = collect($schedule->events())->filter(
fn ($e) => str_contains((string) $e->description, 'App\\Jobs\\')
);
expect($jobEvents)->not->toBeEmpty();
$jobEvents->each(function ($event) {
expect($event->onOneServer)->toBeTrue(
"Scheduled job [{$event->description}] is missing ->onOneServer()"
);
});
});
@@ -0,0 +1,257 @@
<?php
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
});
function cacheGithubAppSetupState(string $state, string $action, GithubApp $githubApp): void
{
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
'action' => $action,
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
], now()->addMinutes(15));
}
function authenticateGithubSetupCallbackTest(object $test): void
{
$test->actingAs($test->user);
session(['currentTeam' => $test->team]);
}
function fakeGithubManifestConversion(): void
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/app-manifests/*/conversions' => Http::response([
'id' => 987654,
'slug' => 'attacker-controlled-app',
'client_id' => 'new-client-id',
'client_secret' => 'new-client-secret',
'pem' => $privateKey,
'webhook_secret' => 'new-webhook-secret',
]),
]);
}
function configureGithubAppCredentials(GithubApp $githubApp): void
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
$privateKeyModel = PrivateKey::create([
'name' => 'github-app-test-key',
'private_key' => $privateKey,
'team_id' => $githubApp->team_id,
'is_git_related' => true,
]);
$githubApp->forceFill([
'app_id' => 123456,
'private_key_id' => $privateKeyModel->id,
])->save();
}
function fakeGithubInstallationVerification(int $appId): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/*' => Http::response([
'id' => 555,
'app_id' => $appId,
], 200),
]);
}
function fakeGithubInstallationVerificationFailure(): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/*' => Http::response(['message' => 'Not Found'], 404),
]);
}
it('requires authentication before processing github app manifest callbacks', function () {
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
->assertRedirect();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBeNull()
->and($this->githubApp->client_id)->toBeNull()
->and($this->githubApp->webhook_secret)->toBeNull();
});
it('rejects github app manifest callbacks with invalid state without calling github', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state='.$this->githubApp->uuid.'&code=attacker-code')
->assertNotFound();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBeNull()
->and($this->githubApp->client_id)->toBeNull()
->and($this->githubApp->webhook_secret)->toBeNull();
});
it('blocks rebinding an already configured github app through manifest callback', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
$this->githubApp->forceFill([
'app_id' => 123456,
'client_id' => 'existing-client-id',
'client_secret' => 'existing-client-secret',
'webhook_secret' => 'existing-webhook-secret',
])->save();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
->assertForbidden();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBe(123456)
->and($this->githubApp->client_id)->toBe('existing-client-id')
->and($this->githubApp->webhook_secret)->toBe('existing-webhook-secret');
});
it('configures an unbound github app with a valid one-time manifest state', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertSentCount(1);
$this->githubApp->refresh();
expect($this->githubApp->name)->toBe('attacker-controlled-app')
->and($this->githubApp->app_id)->toBe(987654)
->and($this->githubApp->client_id)->toBe('new-client-id')
->and($this->githubApp->webhook_secret)->toBe('new-webhook-secret')
->and($this->githubApp->private_key_id)->not->toBeNull();
});
it('rejects replayed github app manifest states', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertRedirect();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertNotFound();
Http::assertSentCount(1);
});
it('requires authentication before processing github app install callbacks', function () {
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertRedirect();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBeNull();
});
it('rejects github app install callbacks for an unknown github app', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
->assertNotFound();
Http::assertNothingSent();
});
it('rejects an installation id that github does not confirm belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerificationFailure();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
->assertForbidden();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBeNull();
});
it('sets installation id when github confirms it belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('allows reinstalling an already configured github app installation id', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
fakeGithubInstallationVerification($this->githubApp->app_id);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(222222);
});
@@ -0,0 +1,119 @@
<?php
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
Cache::flush();
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
$this->token = $this->server->settings->sentinel_token;
});
function pushSentinel(string $token, array $payload)
{
return test()->postJson('/api/v1/sentinel/push', $payload, [
'Authorization' => 'Bearer '.$token,
]);
}
function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): array
{
return [
'containers' => $containers,
'filesystem_usage_root' => ['used_percentage' => $diskPercentage],
];
}
$running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
it('dispatches the job on the first push', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('skips the job when the second push is identical', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('updates the heartbeat even when the job is skipped', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
});
it('dispatches the job when container state changes', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
$exited = [['name' => 'app-1', 'state' => 'exited', 'health_status' => 'unhealthy']];
pushSentinel($this->token, sentinelPayload($exited))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
it('ignores disk percentage changes (excluded from the hash)', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk();
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('ignores container reordering (hash is sorted by name)', function () {
$order1 = [
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
];
$order2 = [
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
];
pushSentinel($this->token, sentinelPayload($order1))->assertOk();
pushSentinel($this->token, sentinelPayload($order2))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('force-dispatches an identical push after the force window expires', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
// Simulate the force key TTL elapsing.
Cache::forget('sentinel:push-force:'.$this->server->id);
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
it('rejects an invalid token without dispatching', function () use ($running) {
pushSentinel('not-a-real-token', sentinelPayload($running()))->assertUnauthorized();
Queue::assertNotPushed(PushServerUpdateJob::class);
});
@@ -0,0 +1,141 @@
<?php
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\Heading;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('cache.default', 'array');
Config::set('app.maintenance.store', 'array');
Config::set('queue.default', 'sync');
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
Once::flush();
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'team-a-network',
]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'network' => 'team-b-network',
]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->otherService = Service::factory()->create([
'server_id' => $this->serverB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => $this->destinationB->getMorphClass(),
'environment_id' => $this->environmentB->id,
]);
$this->otherServiceApplication = ServiceApplication::create([
'service_id' => $this->otherService->id,
'name' => 'other-app',
'image' => 'nginx:alpine',
]);
$this->otherServiceDatabase = ServiceDatabase::create([
'service_id' => $this->otherService->id,
'name' => 'other-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->ownService = Service::factory()->create([
'server_id' => $this->serverA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
'environment_id' => $this->environmentA->id,
]);
$this->ownServiceDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'own-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('does not open service application detail route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceApplication->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not open service database backups route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not resolve service database import component from another team', function () {
$component = app(DatabaseImport::class);
$component->parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
];
$component->getContainers();
})->throws(ModelNotFoundException::class);
test('service heading does not hydrate with another team service', function () {
Livewire::test(Heading::class, ['service' => $this->otherService]);
})->throws(ModelNotFoundException::class);
test('owner can still hydrate service heading with own service', function () {
Livewire::test(Heading::class, [
'service' => $this->ownService,
'parameters' => [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
],
])
->assertOk();
});
+292
View File
@@ -0,0 +1,292 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
/**
* SSH multiplexing now relies on OpenSSH's native lazy ControlMaster handling.
* Coolify should add mux options to real ssh/scp commands, but must not pre-warm
* background masters with separate `ssh -fN` processes.
*/
uses(RefreshDatabase::class);
function makeMuxServer(): Server
{
$user = User::factory()->create();
$team = $user->teams()->first();
$privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n".
"QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n".
"hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n".
"AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n".
"uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n".
'-----END OPENSSH PRIVATE KEY-----';
$privateKey = PrivateKey::create([
'name' => 'mux-test-key-'.uniqid(),
'private_key' => $privateKeyContent,
'team_id' => $team->id,
]);
Storage::fake('ssh-keys');
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
return Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
}
it('does not prewarm a background ssh master', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake();
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
Process::assertNothingRan();
});
it('adds native openssh multiplexing options to ssh commands', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
Process::fake();
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->not->toContain('-O check')
->not->toContain('ssh -fN');
Process::assertNothingRan();
});
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true);
expect($command)
->not->toContain('-o ControlMaster=auto')
->not->toContain('-o ControlPath=')
->not->toContain('-o ControlPersist=');
});
it('adds native openssh multiplexing options to scp commands', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake();
$command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->not->toContain('-O check')
->not->toContain('ssh -fN');
Process::assertNothingRan();
});
it('returns false and runs no process when multiplexing is globally disabled', function () {
config(['constants.ssh.mux_enabled' => false]);
$server = makeMuxServer();
Process::fake();
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse();
Process::assertNothingRan();
});
it('kills only old orphaned ssh masters whose control socket no longer exists', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$liveSocket = $muxDir.'/mux_live_'.uniqid();
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
$youngSocket = $muxDir.'/mux_young_'.uniqid();
File::put($liveSocket, 'x');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4\n".
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n".
"333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333'));
File::delete($liveSocket);
});
it('kills old orphaned native openssh mux masters whose control socket no longer exists', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$liveSocket = $muxDir.'/mux_native_live_'.uniqid();
$orphanSocket = $muxDir.'/mux_native_orphan_'.uniqid();
File::put($liveSocket, 'x');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh: {$liveSocket} [mux]\n".
"222 1 5000 ssh: {$orphanSocket} [mux]\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
File::delete($liveSocket);
});
it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
Process::fake([
'ps*' => Process::result(output: "100 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4\n".
"200 100 5000 cloudflared access ssh --hostname host.example.com\n".
"300 2176 5000 cloudflared access ssh --hostname host.example.com\n".
"400 2176 30 cloudflared access ssh --hostname host.example.com\n".
"2176 1 9000 /usr/bin/some-supervisor\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedCloudflaredProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '300'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '200'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '400'));
});
it('dry-run mode logs orphans but kills nothing when reaping is disabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
Process::fake([
'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill'));
});
it('resets duplicate ssh mux process groups atomically when reaping is enabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$controlPath = $muxDir.'/mux_duplicate_'.uniqid();
File::put($controlPath, 'socket');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n".
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
expect(file_exists($controlPath))->toBeFalse();
});
it('resets duplicate native openssh mux process groups atomically when reaping is enabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$controlPath = $muxDir.'/mux_native_duplicate_'.uniqid();
File::put($controlPath, 'socket');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh: {$controlPath} [mux]\n".
"222 1 5000 ssh: {$controlPath} [mux]\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
expect(file_exists($controlPath))->toBeFalse();
});
it('removes mux files for non-existent servers when reaping is enabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
Storage::fake('ssh-mux');
$file = 'mux_ghost'.uniqid();
Storage::disk('ssh-mux')->put($file, 'x');
Process::fake();
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
$method->setAccessible(true);
$method->invoke($job);
expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse();
});
it('keeps mux files for non-existent servers in dry-run mode', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
Storage::fake('ssh-mux');
$file = 'mux_ghost'.uniqid();
Storage::disk('ssh-mux')->put($file, 'x');
Process::fake();
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
$method->setAccessible(true);
$method->invoke($job);
expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue();
Process::assertNothingRan();
});
+225 -4
View File
@@ -51,7 +51,7 @@ describe('GitHub Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
@@ -118,7 +118,7 @@ describe('GitLab Manual Webhook HMAC', function () {
]);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with wrong token', function () {
@@ -178,7 +178,7 @@ describe('Bitbucket Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with non-sha256 algorithm', function () {
@@ -263,7 +263,7 @@ describe('Gitea Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
@@ -312,6 +312,227 @@ describe('Gitea Manual Webhook HMAC', function () {
});
});
describe('Manual Webhook Repository Matching', function () {
test('github rejects empty repository without leaking applications', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github does not match repository substrings', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('No applications found')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github invalid signature does not leak matched application identifiers', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid signature')
->not->toContain('secret-github-app')
->not->toContain($app->uuid)
->not->toContain('application_uuid')
->not->toContain('application_name');
});
test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => ''],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => ''],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('No applications found')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => 'test-org/test'],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('github matches ssh git repository URL exactly', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@github.com:test-org/test-repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('github matches repository case-insensitively', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'https://github.com/Test-Org/Test-Repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
});
describe('Webhook Secret Auto-Generation', function () {
test('auto-generates webhook secrets on application creation', function () {
$app = createApplicationWithWebhook();
@@ -2,6 +2,9 @@
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
uses(TestCase::class);
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
+2 -2
View File
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.1.0"
"version": "4.1.1"
},
"nightly": {
"version": "4.0.0"
"version": "4.2.0"
},
"helper": {
"version": "1.0.14"
-14
View File
@@ -1,6 +1,5 @@
import { defineConfig, loadEnv } from "vite";
import laravel from "laravel-vite-plugin";
import vue from "@vitejs/plugin-vue";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
@@ -36,19 +35,6 @@ export default defineConfig(({ mode }) => {
input: ["resources/css/app.css", "resources/js/app.js"],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
resolve: {
alias: {
vue: "vue/dist/vue.esm-bundler.js",
},
},
}
});