Compare commits

...

20 commits

Author SHA1 Message Date
Jonathan Vargas
2822267053
Merge 0dc05ce5e9 into 633b1803e1 2026-03-10 16:42:47 -05:00
Jonathan Vargas
0dc05ce5e9
Merge branch 'next' into next 2026-03-10 16:42:45 -05:00
Andras Bacsai
633b1803e1
fix(docker): prevent false container exits on failed docker queries (#8860) 2026-03-10 21:59:47 +01:00
Andras Bacsai
458f048c4e fix(push-server): track last_online_at and reset database restart state
- Update last_online_at timestamp when resource status is confirmed active
- Reset restart_count, last_restart_at, and last_restart_type when marking database as exited
- Remove unused updateServiceSubStatus() method
2026-03-10 21:46:26 +01:00
Andras Bacsai
0a1782175a Merge remote-tracking branch 'origin/next' into 8826-investigate-postgresql-restart 2026-03-10 21:46:03 +01:00
Andras Bacsai
a3e59e5c96
fix(docker-cleanup): respect keep for rollback setting for Nixpacks build images (#8859) 2026-03-10 21:42:45 +01:00
Andras Bacsai
d6ac8de6b7 Merge remote-tracking branch 'origin/next' into 8765-investigate-docker-cleanup-keep 2026-03-10 21:41:25 +01:00
Andras Bacsai
473371e7ed chore(realtime): upgrade coolify-realtime to 1.0.11 2026-03-10 21:14:30 +01:00
Andras Bacsai
b71d1561f3 chore(realtime): upgrade npm dependencies
Update dependencies in coolify-realtime package:
- @xterm/addon-fit 0.10.0 → 0.11.0
- @xterm/xterm 5.5.0 → 6.0.0
- axios 1.12.0 → 1.13.6
- cookie 1.0.2 → 1.1.1
- dotenv 16.5.0 → 17.3.1
- node-pty 1.0.0 → 1.1.0 (now uses node-addon-api instead of nan)
- ws 8.18.1 → 8.19.0
2026-03-10 21:07:14 +01:00
Andras Bacsai
d46c2c8152
fix(terminal): resolve WebSocket connection and host authorization issues (#8862) 2026-03-10 20:57:14 +01:00
Andras Bacsai
1d3dfe4dc8 chore(version): bump coolify, realtime, and sentinel versions 2026-03-10 20:40:49 +01:00
Andras Bacsai
5c5f67f48b chore: prepare for PR 2026-03-10 20:37:22 +01:00
Andras Bacsai
e41dbde46b chore: prepare for PR 2026-03-10 18:34:37 +01:00
Andras Bacsai
9702543e20 chore: prepare for PR 2026-03-10 18:32:19 +01:00
Andras Bacsai
201998638a
fix(env-parser): capture clean variable names without trailing braces in bash-style defaults (#8855) 2026-03-10 18:06:51 +01:00
Andras Bacsai
0679e91c85 fix(parser): use firstOrCreate instead of updateOrCreate for environment variables
Prevent unnecessary updates to existing environment variable records.
The previous implementation would update matching records, but the intent
is to retrieve or create the record without modifying existing ones.
2026-03-10 18:06:01 +01:00
Andras Bacsai
a362282976 chore: prepare for PR 2026-03-10 17:37:13 +01:00
Andras Bacsai
872e300cf9 fix(subscription): use optional chaining for preview object access
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
Add optional chaining operator (?.) to all preview property accesses in the
subscription actions view to prevent potential null reference errors when the
preview object is undefined.
2026-03-10 17:14:08 +01:00
Andras Bacsai
470cc15e62 feat(jobs): implement encrypted queue jobs
- Add ShouldBeEncrypted interface to all queue jobs to encrypt sensitive
  job payloads
- Configure explicit retry policies for messaging jobs (5 attempts,
  10-second backoff)
2026-03-10 14:05:05 +01:00
Jona
488ab0ceae Added EspoCRM service template 2026-03-06 12:47:54 -05:00
38 changed files with 1180 additions and 247 deletions

View file

@ -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',

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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

View file

@ -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.
*/

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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;
}

View file

@ -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'),

View file

@ -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}"

View file

@ -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}"

View file

@ -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"

View file

@ -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 \

View file

@ -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"

View file

@ -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"
}
}

View file

@ -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,
});
});

View 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);
}

View 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
View 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

View file

@ -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);
}
},

View file

@ -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'));
}">

View file

@ -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>

View file

@ -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);

View 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

View 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');
});

View 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!');");
});

View 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');
});

View file

@ -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);
});

View file

@ -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()');
});

View file

@ -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}}';

View file

@ -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"
}
}
}