mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Compare commits
20 commits
135f6a033e
...
2822267053
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2822267053 | ||
|
|
0dc05ce5e9 | ||
|
|
633b1803e1 | ||
|
|
458f048c4e | ||
|
|
0a1782175a | ||
|
|
a3e59e5c96 | ||
|
|
d6ac8de6b7 | ||
|
|
473371e7ed | ||
|
|
b71d1561f3 | ||
|
|
d46c2c8152 | ||
|
|
1d3dfe4dc8 | ||
|
|
5c5f67f48b | ||
|
|
e41dbde46b | ||
|
|
9702543e20 | ||
|
|
201998638a | ||
|
|
0679e91c85 | ||
|
|
a362282976 | ||
|
|
872e300cf9 | ||
|
|
470cc15e62 | ||
|
|
488ab0ceae |
38 changed files with 1180 additions and 247 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1509,6 +1509,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 +1698,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 +1940,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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.464',
|
||||
'version' => '4.0.0-beta.465',
|
||||
'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);
|
||||
});
|
||||
82
public/svgs/espocrm.svg
Normal file
82
public/svgs/espocrm.svg
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="379.36536"
|
||||
height="83.256203"
|
||||
enable-background="new 0 0 307.813 75"
|
||||
overflow="visible"
|
||||
version="1.1"
|
||||
viewBox="0 0 303.49228 66.604962"
|
||||
xml:space="preserve"
|
||||
id="svg20"
|
||||
sodipodi:docname="logo2.svg"
|
||||
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs24" /><sodipodi:namedview
|
||||
id="namedview22"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
scale-x="0.8"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:zoom="2.172956"
|
||||
inkscape:cx="109.75832"
|
||||
inkscape:cy="79.384949"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1074"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg20" />
|
||||
|
||||
<switch
|
||||
transform="matrix(1.089,0,0,1.089,-14.949525,-4.9304545)"
|
||||
id="switch18">
|
||||
<foreignObject
|
||||
width="1"
|
||||
height="1"
|
||||
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/">
|
||||
|
||||
</foreignObject>
|
||||
<g
|
||||
transform="matrix(0.96767,0,0,0.96767,3.9659,-1.2011)"
|
||||
id="g16">
|
||||
|
||||
<path
|
||||
d="m 169.53,21.864 c -7.453,2.972 -9.569,11.987 -9.005,19.212 1.587,2.982 3.845,5.562 5.783,8.312 l 4.262,-1.083 c -1.796,-4.447 -1.689,-9.424 -0.806,-14.066 0.585,-3.001 2.309,-6.476 5.634,-7.032 5.307,-0.847 10.733,-0.271 16.088,-0.369 0.091,-2.196 0.115,-4.392 0.107,-6.585 -7.333,0.387 -15.043,-1.038 -22.063,1.611 z m 52.714,-1.294 c -8.12,-0.952 -16.332,-0.149 -24.492,-0.387 -0.021,6.43 -0.003,12.854 0.078,19.274 2.625,-0.849 5.251,-1.739 7.909,-2.532 0.042,-3.272 0.028,-6.527 -0.071,-9.789 4.869,-0.029 9.874,-0.757 14.639,0.451 1.838,0.298 2.051,2.25 2.687,3.641 2.541,-0.891 5.111,-1.717 7.672,-2.574 -0.703,-4.246 -4.129,-7.633 -8.422,-8.084 z m 23.522,-0.593 c -3.954,0.072 -7.912,0.064 -11.864,0.047 0.051,2.544 0.063,5.074 0.072,7.617 4.263,-1.482 8.553,-2.889 12.848,-4.268 -0.35,-1.128 -0.706,-2.268 -1.056,-3.396 z"
|
||||
fill="#6a3201"
|
||||
id="path2" />
|
||||
<path
|
||||
d="m 161.96,69.125 c 7.886,-3.717 15.757,-7.463 23.72,-11.018 5.563,0.359 11.146,0.021 16.722,0.193 1.14,-0.036 2.292,-0.061 3.432,-0.088 -0.011,-3.195 -0.025,-6.38 -0.082,-9.564 3.428,-1.502 10.227,-4.623 10.227,-4.623 l 15.215,13.941 11.096,0.106 -0.715,-26.236 0.803,-0.211 9.005,26.344 8.834,-0.066 8.99,-28.394 -0.308,28.434 8.074,-0.021 -0.231,-37.932 -9.279,0.071 30.625,-14.141 c 0,0 -37.593,14.279 -56.404,21.385 -2.996,1.022 -5.878,2.315 -8.853,3.394 -2.278,0.867 -4.558,1.713 -6.834,2.58 -20.071,7.526 -39.945,15.604 -60.126,22.803 C 159.094,45.56 150.557,36.228 144.103,25.497 Z m 72.116,-17.961 c -0.108,0.154 -0.324,0.458 -0.429,0.611 -3.448,-3.018 -6.765,-6.189 -10.21,-9.205 1.745,-1.096 3.47,-2.242 5.026,-3.597 1.625,-1.386 3.479,-2.469 5.345,-3.499 0.293,5.227 0.258,10.452 0.268,15.69 z m 23.942,-9.67 c -0.857,2.578 -1.825,5.137 -2.793,7.682 -1.644,-6.217 -3.94,-12.238 -5.856,-18.383 -0.119,-0.52 -0.366,-1.574 -0.487,-2.093 3.428,-1.709 10.585,-4.854 15.229,-6.815 -1.647,5.969 -4.306,14.029 -6.093,19.609 z"
|
||||
fill="#ffb300"
|
||||
id="path4" />
|
||||
|
||||
<g
|
||||
fill="#6a3201"
|
||||
id="g14">
|
||||
<path
|
||||
d="M 45.672,58.148 H 27.146 c -2.861,0 -5.614,-0.651 -8.257,-1.953 -2.861,-1.409 -5.043,-3.651 -6.547,-6.725 -1.503,-3.074 -2.254,-6.455 -2.254,-10.145 0,-3.652 0.724,-6.961 2.173,-9.926 1.594,-3.219 3.803,-5.569 6.628,-7.052 1.557,-0.795 3.052,-1.355 4.482,-1.682 1.43,-0.325 3.07,-0.488 4.917,-0.488 h 17.168 v 6.789 H 29.57 c -1.415,0 -2.602,0.187 -3.563,0.558 -0.961,0.372 -1.912,1.037 -2.855,1.994 -0.943,0.957 -1.597,1.887 -1.959,2.791 -0.363,0.902 -0.543,2.027 -0.543,3.375 h 25.023 v 6.789 H 20.648 c 0,1.24 0.164,2.325 0.491,3.256 0.327,0.93 0.919,1.887 1.776,2.871 0.856,0.985 1.749,1.732 2.677,2.242 0.929,0.512 2.03,0.767 3.306,0.767 h 16.774 z"
|
||||
id="path6" />
|
||||
<path
|
||||
d="m 76.499,49.519 c 0,2.397 -0.771,4.449 -2.312,6.154 -1.541,1.706 -3.49,2.56 -5.846,2.56 H 49.688 V 53.12 h 15.326 c 1.087,0 2.001,-0.272 2.744,-0.817 0.743,-0.545 1.115,-1.327 1.115,-2.345 0,-2.362 -1.595,-3.543 -4.783,-3.543 h -7.825 c -1.666,0 -3.278,-0.79 -4.836,-2.369 -1.559,-1.58 -2.336,-3.287 -2.336,-5.119 0,-2.585 0.579,-4.667 1.738,-6.248 1.34,-1.794 3.313,-2.692 5.922,-2.692 h 17.928 v 5.364 H 58.743 c -0.614,0 -1.147,0.289 -1.599,0.868 -0.452,0.579 -0.677,1.235 -0.677,1.972 0,0.807 0.298,1.498 0.896,2.076 0.597,0.579 1.311,0.867 2.144,0.867 h 8.415 c 2.643,0 4.733,0.79 6.271,2.369 1.536,1.579 2.306,3.584 2.306,6.016 z"
|
||||
id="path8" />
|
||||
<path
|
||||
d="m 109.29,43.414 c 0,4.495 -1.166,8.074 -3.497,10.738 -2.331,2.664 -5.395,3.996 -9.188,3.996 H 88.419 V 68.457 H 80.792 V 29.985 h 15.09 c 4.27,0 7.6,1.269 9.989,3.806 2.279,2.428 3.419,5.637 3.419,9.623 z m -7.627,0.405 c 0,-2.356 -0.754,-4.286 -2.262,-5.793 -1.509,-1.505 -3.388,-2.258 -5.641,-2.258 h -5.341 v 16.429 h 5.886 c 2.179,0 3.951,-0.771 5.313,-2.313 1.363,-1.54 2.045,-3.562 2.045,-6.065 z"
|
||||
id="path10" />
|
||||
<path
|
||||
d="m 145.1,43.967 c 0,4.896 -1.557,8.65 -4.669,11.261 -2.86,2.394 -6.751,3.591 -11.673,3.591 -4.923,0 -8.742,-1.087 -11.456,-3.264 -3.15,-2.502 -4.724,-6.401 -4.724,-11.696 0,-4.424 1.701,-7.906 5.104,-10.446 3.04,-2.283 6.786,-3.427 11.238,-3.427 4.887,0 8.805,1.225 11.754,3.673 2.949,2.448 4.426,5.884 4.426,10.308 z m -8.382,-0.065 c 0,-2.285 -0.716,-4.197 -2.146,-5.738 -1.432,-1.54 -3.379,-2.312 -5.841,-2.312 -2.246,0 -4.103,0.79 -5.57,2.366 -1.467,1.577 -2.2,3.563 -2.2,5.955 0,2.756 0.743,4.949 2.228,6.581 1.485,1.632 3.405,2.448 5.76,2.448 2.679,0 4.673,-0.852 5.977,-2.557 1.193,-1.557 1.792,-3.805 1.792,-6.743 z"
|
||||
id="path12" />
|
||||
</g>
|
||||
</g>
|
||||
</switch>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
85
templates/compose/espocrm.yaml
Normal file
85
templates/compose/espocrm.yaml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# documentation: https://docs.espocrm.com/
|
||||
# slogan: Free Self-Hosted CRM Software
|
||||
# category: crm
|
||||
# tags: crm, helpdesk, support, ticketing, customer-support, postgresql, open-source, self-hosted
|
||||
# logo: svgs/espocrm.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
espocrm:
|
||||
image: espocrm/espocrm:latest
|
||||
environment:
|
||||
- SERVICE_URL_ESPOCRM_80
|
||||
- ESPOCRM_DATABASE_PLATFORM=Postgresql
|
||||
- ESPOCRM_DATABASE_HOST=espocrm-db
|
||||
- ESPOCRM_DATABASE_PORT=5432
|
||||
- ESPOCRM_DATABASE_NAME=${POSTGRES_DB:-espocrm}
|
||||
- ESPOCRM_DATABASE_USER=$SERVICE_USER_POSTGRES
|
||||
- ESPOCRM_DATABASE_PASSWORD=$SERVICE_PASSWORD_POSTGRES
|
||||
- ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin}
|
||||
- ESPOCRM_ADMIN_PASSWORD=$SERVICE_PASSWORD_ADMIN
|
||||
- ESPOCRM_SITE_URL=$SERVICE_URL_ESPOCRM
|
||||
- ESPOCRM_LANGUAGE=${ESPOCRM_LANGUAGE:-en_US}
|
||||
- ESPOCRM_TIME_ZONE=${ESPOCRM_TIME_ZONE:-UTC}
|
||||
- ESPOCRM_DEFAULT_CURRENCY=${ESPOCRM_DEFAULT_CURRENCY:-USD}
|
||||
- ESPOCRM_DATE_FORMAT=${ESPOCRM_DATE_FORMAT:-MM/DD/YYYY}
|
||||
- ESPOCRM_TIME_FORMAT=${ESPOCRM_TIME_FORMAT:-HH:mm}
|
||||
- ESPOCRM_CONFIG_USE_WEB_SOCKET=${ESPOCRM_CONFIG_USE_WEB_SOCKET:-true}
|
||||
- ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET
|
||||
- ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777
|
||||
- ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777
|
||||
- ESPOCRM_CONFIG_OUTBOUND_EMAIL_IS_SHARED=${ESPOCRM_CONFIG_OUTBOUND_EMAIL_IS_SHARED:-false}
|
||||
- ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_NAME=${SMTP_FROM_NAME}
|
||||
- ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_ADDRESS=${SMTP_FROM_ADDRESS}
|
||||
- ESPOCRM_CONFIG_SMTP_SERVER=${SMTP_SERVER}
|
||||
- ESPOCRM_CONFIG_SMTP_PORT=${SMTP_PORT:-587}
|
||||
- ESPOCRM_CONFIG_SMTP_AUTH=${SMTP_AUTH:-true}
|
||||
- ESPOCRM_CONFIG_SMTP_SECURITY=${SMTP_SECURITY:-TLS}
|
||||
- ESPOCRM_CONFIG_SMTP_USERNAME=${SMTP_USERNAME}
|
||||
- ESPOCRM_CONFIG_SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
volumes:
|
||||
- espocrm-data:/var/www/html
|
||||
depends_on:
|
||||
espocrm-db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://127.0.0.1:80 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
espocrm-daemon:
|
||||
image: espocrm/espocrm:latest
|
||||
entrypoint: docker-daemon.sh
|
||||
volumes:
|
||||
- espocrm-data:/var/www/html
|
||||
depends_on:
|
||||
- espocrm
|
||||
|
||||
espocrm-websocket:
|
||||
image: espocrm/espocrm:latest
|
||||
entrypoint: docker-websocket.sh
|
||||
environment:
|
||||
- SERVICE_URL_ESPOCRM_WEBSOCKET_8080
|
||||
- ESPOCRM_CONFIG_USE_WEB_SOCKET=${ESPOCRM_CONFIG_USE_WEB_SOCKET:-true}
|
||||
- ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET
|
||||
- ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777
|
||||
- ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777
|
||||
volumes:
|
||||
- espocrm-data:/var/www/html
|
||||
depends_on:
|
||||
- espocrm
|
||||
|
||||
espocrm-db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB:-espocrm}
|
||||
- POSTGRES_USER=$SERVICE_USER_POSTGRES
|
||||
- POSTGRES_PASSWORD=$SERVICE_PASSWORD_POSTGRES
|
||||
volumes:
|
||||
- espocrm-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
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!');");
|
||||
});
|
||||
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()');
|
||||
});
|
||||
|
|
@ -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}}';
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.464"
|
||||
"version": "4.0.0-beta.465"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.465"
|
||||
"version": "4.0.0-beta.466"
|
||||
},
|
||||
"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