mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-19 07:35:25 +00:00
Merge remote-tracking branch 'origin/next' into fix/form-state
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [])
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -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
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+1
-478
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+3
-4
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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']],
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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,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",
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user