Merge branch 'v4.x' into chore/gitea-runner-version-bump

This commit is contained in:
Michalzarddev 2026-06-06 00:13:20 +02:00 committed by GitHub
commit cfd31327bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
370 changed files with 18563 additions and 7215 deletions

View file

@ -15,6 +15,18 @@ DB_PASSWORD=password
DB_HOST=host.docker.internal DB_HOST=host.docker.internal
DB_PORT=5432 DB_PORT=5432
# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split.
# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset.
# DB_READ_HOST=replica1,replica2
# DB_READ_PORT=5432
# DB_READ_USERNAME=coolify
# DB_READ_PASSWORD=
# DB_WRITE_HOST=
# DB_WRITE_PORT=5432
# DB_WRITE_USERNAME=coolify
# DB_WRITE_PASSWORD=
# DB_STICKY=true
# Ray Configuration # Ray Configuration
# Set to true to enable Ray # Set to true to enable Ray
RAY_ENABLED=false RAY_ENABLED=false

View file

@ -59,6 +59,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs * [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control * [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
@ -70,7 +71,6 @@ ### Big Sponsors
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers * [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale * [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor * [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor

View file

@ -13,7 +13,7 @@ class StopApplication
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
{ {
$servers = collect([$application->destination->server]); $servers = collect([$application->destination->server]);
if ($application?->additional_servers?->count() > 0) { if ($application?->additional_servers?->count() > 0) {
@ -57,12 +57,17 @@ public function handle(Application $application, bool $previewDeployments = fals
} }
} }
// Reset restart tracking when application is manually stopped if ($resetRestartCount) {
$application->update([ $application->update([
'restart_count' => 0, 'restart_count' => 0,
'last_restart_at' => null, 'last_restart_at' => null,
'last_restart_type' => null, 'last_restart_type' => null,
]); ]);
} else {
$application->update([
'status' => 'exited',
]);
}
ServiceStatusChanged::dispatch($application->environment->project->team->id); ServiceStatusChanged::dispatch($application->environment->project->team->id);
} }

View file

@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database)
], ],
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -98,6 +94,9 @@ public function handle(StandaloneClickhouse $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -11,12 +11,16 @@
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
class StartDatabase class StartDatabase
{ {
use AsAction; use AsAction;
public string $jobQueue = 'high'; public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{ {
@ -25,28 +29,28 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
return 'Server is not functional'; return 'Server is not functional';
} }
switch ($database->getMorphClass()) { switch ($database->getMorphClass()) {
case \App\Models\StandalonePostgresql::class: case StandalonePostgresql::class:
$activity = StartPostgresql::run($database); $activity = StartPostgresql::run($database);
break; break;
case \App\Models\StandaloneRedis::class: case StandaloneRedis::class:
$activity = StartRedis::run($database); $activity = StartRedis::run($database);
break; break;
case \App\Models\StandaloneMongodb::class: case StandaloneMongodb::class:
$activity = StartMongodb::run($database); $activity = StartMongodb::run($database);
break; break;
case \App\Models\StandaloneMysql::class: case StandaloneMysql::class:
$activity = StartMysql::run($database); $activity = StartMysql::run($database);
break; break;
case \App\Models\StandaloneMariadb::class: case StandaloneMariadb::class:
$activity = StartMariadb::run($database); $activity = StartMariadb::run($database);
break; break;
case \App\Models\StandaloneKeydb::class: case StandaloneKeydb::class:
$activity = StartKeydb::run($database); $activity = StartKeydb::run($database);
break; break;
case \App\Models\StandaloneDragonfly::class: case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database); $activity = StartDragonfly::run($database);
break; break;
case \App\Models\StandaloneClickhouse::class: case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database); $activity = StartClickhouse::run($database);
break; break;
} }

View file

@ -11,14 +11,19 @@
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Notifications\Container\ContainerRestarted;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class StartDatabaseProxy class StartDatabaseProxy
{ {
use AsAction; use AsAction;
public string $jobQueue = 'high'; public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
{ {
@ -29,7 +34,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$proxyContainerName = "{$database->uuid}-proxy"; $proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false; $isSSLEnabled = $database->enable_ssl ?? false;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($database->getMorphClass() === ServiceDatabase::class) {
$databaseType = $database->databaseType(); $databaseType = $database->databaseType();
$network = $database->service->uuid; $network = $database->service->uuid;
$server = data_get($database, 'service.destination.server'); $server = data_get($database, 'service.destination.server');
@ -132,7 +137,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
?? data_get($database, 'service.environment.project.team'); ?? data_get($database, 'service.environment.project.team');
$team?->notify( $team?->notify(
new \App\Notifications\Container\ContainerRestarted( new ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server, $server,
) )

View file

@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -182,6 +178,9 @@ public function handle(StandaloneDragonfly $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -108,13 +108,9 @@ public function handle(StandaloneKeydb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database)
// Add custom docker run options // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -103,13 +103,9 @@ public function handle(StandaloneMariadb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -202,6 +198,9 @@ public function handle(StandaloneMariadb $database)
]; ];
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -109,17 +109,11 @@ public function handle(StandaloneMongodb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => [ 'CMD',
'CMD', 'echo',
'echo', 'ok',
'ok', ]),
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -253,6 +247,9 @@ public function handle(StandaloneMongodb $database)
$docker_compose['services'][$container_name]['command'] = $commandParts; $docker_compose['services'][$container_name]['command'] = $commandParts;
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -103,13 +103,9 @@ public function handle(StandaloneMysql $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"], 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -203,6 +199,9 @@ public function handle(StandaloneMysql $database)
]; ];
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -110,13 +110,9 @@ public function handle(StandalonePostgresql $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'], 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -213,6 +209,9 @@ public function handle(StandalonePostgresql $database)
$docker_compose['services'][$container_name]['command'] = $command; $docker_compose['services'][$container_name]['command'] = $command;
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => [ 'CMD-SHELL',
'CMD-SHELL', 'redis-cli',
'redis-cli', 'ping',
'ping', ]),
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -194,6 +188,9 @@ public function handle(StandaloneRedis $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -2,6 +2,7 @@
namespace App\Actions\Docker; namespace App\Actions\Docker;
use App\Actions\Application\StopApplication;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Shared\ComplexStatusCheck; use App\Actions\Shared\ComplexStatusCheck;
@ -9,6 +10,7 @@
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Services\ContainerStatusAggregator; use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus; use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -464,7 +466,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
} }
// Wrap all database updates in a transaction to ensure consistency // Wrap all database updates in a transaction to ensure consistency
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) { $restartLimitReached = false;
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
$previousRestartCount = $application->restart_count ?? 0; $previousRestartCount = $application->restart_count ?? 0;
if ($maxRestartCount > $previousRestartCount) { if ($maxRestartCount > $previousRestartCount) {
@ -475,16 +479,10 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
'last_restart_type' => 'crash', 'last_restart_type' => 'crash',
]); ]);
// Send notification // Check if restart limit has been reached
$containerName = $application->name; $maxAllowedRestarts = $application->max_restart_count ?? 0;
$projectUuid = data_get($application, 'environment.project.uuid'); if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
$environmentName = data_get($application, 'environment.name'); $restartLimitReached = true;
$applicationUuid = data_get($application, 'uuid');
if ($projectUuid && $applicationUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
} else {
$url = null;
} }
} }
@ -499,6 +497,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
} }
} }
}); });
if ($restartLimitReached) {
$application->refresh();
StopApplication::dispatch($application, false, true, false);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
} }
} }

View file

@ -2,6 +2,7 @@
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -44,7 +45,10 @@ public function create(array $input): User
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
]); ]);
$user->save(); $user->save();
$team = $user->teams()->first(); $team = $user->teams()->first() ?? Team::find(0);
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team, ['role' => 'owner']);
}
// Disable registration after first user is created // Disable registration after first user is created
$settings = instanceSettings(); $settings = instanceSettings();

View file

@ -51,7 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd, $imagePruneCmd,
'docker builder prune -af', 'docker builder prune -af',
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true', "docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",

View file

@ -1,41 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Application;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class ResourcesCheck
{
use AsAction;
public function handle()
{
$seconds = 60;
try {
Application::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View file

@ -3,6 +3,7 @@
namespace App\Actions\Server; namespace App\Actions\Server;
use App\Models\Server; use App\Models\Server;
use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class StartLogDrain class StartLogDrain
@ -201,10 +202,29 @@ public function handle(Server $server)
"echo 'Starting Fluent Bit'", "echo 'Starting Fluent Bit'",
"cd $config_path && docker compose up -d", "cd $config_path && docker compose up -d",
]; ];
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
return instant_remote_process($command, $server); return instant_remote_process($command, $server);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e);
} }
} }
private function logDrainNetworkConnectCommands(Server $server): array
{
if (! $server->isLogDrainEnabled()) {
return [];
}
return $server->services()
->with('destination')
->where('connect_to_docker_network', true)
->get()
->map(fn (Service $service) => data_get($service, 'destination.network'))
->filter()
->unique()
->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
->values()
->all();
}
} }

View file

@ -13,8 +13,10 @@ class RestartService
public function handle(Service $service, bool $pullLatestImages) public function handle(Service $service, bool $pullLatestImages)
{ {
StopService::run($service); return StartService::run(
service: $service,
return StartService::run($service, $pullLatestImages); pullLatestImages: $pullLatestImages,
stopBeforeStart: true,
);
} }
} }

View file

@ -4,18 +4,22 @@
use App\Models\Service; use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class StartService class StartService
{ {
use AsAction; use AsAction;
public string $jobQueue = 'high'; public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{ {
$service->parse(); $service->parse();
if ($stopBeforeStart) { if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
StopService::run(service: $service, dockerCleanup: false); StopService::run(service: $service, dockerCleanup: false);
} }
$service->saveComposeConfigs(); $service->saveComposeConfigs();
@ -46,7 +50,34 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
} }
} }
$commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
} }
private function logDrainNetworkConnectCommands(Service $service): array
{
if (! data_get($service, 'connect_to_docker_network')) {
return [];
}
if (! $service->destination?->server?->isLogDrainEnabled()) {
return [];
}
$network = data_get($service, 'destination.network');
if (blank($network)) {
return [];
}
return [
'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true',
];
}
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
{
return $stopBeforeStart && ! $pullLatestImages;
}
} }

View file

@ -137,9 +137,11 @@ public function execute(): array
// Update the new owner's role to owner // Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
// Remove the current user from the team // Remove the current user from the team
$team->members()->detach($this->user->id); $team->members()->detach($this->user->id);
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['transferred']++; $counts['transferred']++;
} catch (\Exception $e) { } catch (\Exception $e) {
@ -152,6 +154,7 @@ public function execute(): array
foreach ($preview['to_leave'] as $team) { foreach ($preview['to_leave'] as $team) {
try { try {
$team->members()->detach($this->user->id); $team->members()->detach($this->user->id);
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['left']++; $counts['left']++;
} catch (\Exception $e) { } catch (\Exception $e) {
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage()); \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());

View file

@ -0,0 +1,43 @@
<?php
namespace App\Actions\User;
use App\Models\PersonalAccessToken;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class RevokeUserTeamTokens
{
public static function forUserTeam(User|int $user, int|string $teamId): int
{
return self::baseQuery()
->where('tokenable_id', self::userId($user))
->where('team_id', $teamId)
->delete();
}
public static function forUser(User|int $user): int
{
return self::baseQuery()
->where('tokenable_id', self::userId($user))
->delete();
}
public static function forTeam(int|string $teamId): int
{
return self::baseQuery()
->where('team_id', $teamId)
->delete();
}
private static function baseQuery(): Builder
{
return PersonalAccessToken::query()
->where('tokenable_type', User::class);
}
private static function userId(User|int $user): int
{
return $user instanceof User ? $user->id : $user;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
/**
* Stores an array as an encrypted JSON string at rest. Tolerates legacy
* plaintext JSON rows written before the column was encrypted, so existing
* snapshots keep decoding instead of throwing.
*
* @implements CastsAttributes<array<mixed>|null, array<mixed>|null>
*/
class EncryptedArrayCast implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
* @return array<mixed>|null
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
{
if ($value === null || $value === '') {
return null;
}
try {
$value = Crypt::decryptString($value);
} catch (DecryptException) {
// Legacy plaintext JSON written before this column was encrypted.
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
}
}

View file

@ -18,9 +18,13 @@ public function handle()
if ($servers->count() > 0) { if ($servers->count() > 0) {
foreach ($servers as $server) { foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name"; echo "Cleanup unreachable server ($server->id) with name $server->name";
$server->update([ if (isCloud()) {
'ip' => '1.2.3.4', $server->update([
]); 'ip' => '1.2.3.4',
]);
} else {
$server->forceDisableServer();
}
} }
} }
} }

View file

@ -253,7 +253,7 @@ private function restoreCoolifyDbBackup()
'save_s3' => false, 'save_s3' => false,
'frequency' => '0 0 * * *', 'frequency' => '0 0 * * *',
'database_id' => $database->id, 'database_id' => $database->id,
'database_type' => \App\Models\StandalonePostgresql::class, 'database_type' => StandalonePostgresql::class,
'team_id' => 0, 'team_id' => 0,
]); ]);
} }

View file

@ -16,7 +16,7 @@ class SyncBunny extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}'; protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
/** /**
* The console command description. * The console command description.
@ -25,650 +25,6 @@ class SyncBunny extends Command
*/ */
protected $description = 'Sync files to BunnyCDN'; protected $description = 'Sync files to BunnyCDN';
/**
* Fetch GitHub releases and sync to GitHub repository
*/
private function syncReleasesToGitHubRepo(): bool
{
$this->info('Fetching releases from GitHub...');
try {
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30, // Fetch more releases for better changelog
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
$branchName = 'update-releases-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
// Ensure directory exists
if (! is_dir($releasesDir)) {
$this->info("Creating directory: $releasesDir");
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Releases are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR Output: '.implode("\n", $output));
}
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing releases: '.$e->getMessage());
return false;
}
}
/**
* Sync both releases.json and versions.json to GitHub repository in one PR
*/
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing releases.json and versions.json to GitHub repository...');
try {
// 1. Fetch releases from GitHub API
$this->info('Fetching releases from GitHub API...');
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30,
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
// 2. Read versions.json
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$versionsJson = json_decode($file, true);
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
$branchName = 'update-releases-and-versions-'.$timestamp;
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// 3. Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// 4. Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 5. Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
if (! is_dir($releasesDir)) {
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
$this->error("Failed to write releases.json to: $releasesPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 6. Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$versionsTargetPath";
$versionsDir = dirname($versionsPath);
if (! is_dir($versionsDir)) {
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
$this->error("Failed to write versions.json to: $versionsPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 7. Stage both files
$this->info('Staging changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 8. Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Both files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// 9. Commit changes
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 10. Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 11. Create pull request
$this->info('Creating pull request...');
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// 12. Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
*/
private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
{
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("Syncing $envLabel files to GitHub repository...");
try {
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
$branchName = 'update-files-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Copy each file to its target path in the CDN repo
$copiedFiles = [];
foreach ($files as $sourceFile => $targetPath) {
if (! file_exists($sourceFile)) {
$this->warn("Source file not found, skipping: $sourceFile");
continue;
}
$destPath = "$tmpDir/$targetPath";
$destDir = dirname($destPath);
if (! is_dir($destDir)) {
if (! mkdir($destDir, 0755, true)) {
$this->error("Failed to create directory: $destDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
if (copy($sourceFile, $destPath) === false) {
$this->error("Failed to copy $sourceFile to $destPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$copiedFiles[] = $targetPath;
$this->info("Copied: $targetPath");
}
if (empty($copiedFiles)) {
$this->warn('No files were copied. Nothing to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// Stage all copied files
$this->info('Staging changes...');
$output = [];
$stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
exec($stageCmd, $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('All files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// Commit changes
$commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
$fileList = implode("\n- ", $copiedFiles);
$prBody = "Automated update of $envLabel files:\n- $fileList";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info('Files synced: '.count($copiedFiles));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing files to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync versions.json to GitHub repository via PR
*/
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing versions.json to GitHub repository...');
try {
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$json = json_decode($file, true);
$actualVersion = data_get($json, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
$branchName = 'update-versions-'.$timestamp;
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$targetPath";
$versionsDir = dirname($versionsPath);
// Ensure directory exists
if (! is_dir($versionsDir)) {
$this->info("Creating directory: $versionsDir");
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write versions.json to: $versionsPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('versions.json is already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
$output = [];
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
return true;
} catch (\Throwable $e) {
$this->error('Error syncing versions.json: '.$e->getMessage());
return false;
}
}
/** /**
* Execute the console command. * Execute the console command.
*/ */
@ -677,8 +33,6 @@ public function handle()
$that = $this; $that = $this;
$only_template = $this->option('templates'); $only_template = $this->option('templates');
$only_version = $this->option('release'); $only_version = $this->option('release');
$only_github_releases = $this->option('github-releases');
$only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly'); $nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify'; $bunny_cdn_path = 'coolify';
@ -690,6 +44,7 @@ public function handle()
$compose_file_prod = 'docker-compose.prod.yml'; $compose_file_prod = 'docker-compose.prod.yml';
$install_script = 'install.sh'; $install_script = 'install.sh';
$upgrade_script = 'upgrade.sh'; $upgrade_script = 'upgrade.sh';
$upgrade_postgres_script = 'upgrade-postgres.sh';
$production_env = '.env.production'; $production_env = '.env.production';
$service_template = config('constants.services.file_name'); $service_template = config('constants.services.file_name');
$versions = 'versions.json'; $versions = 'versions.json';
@ -698,6 +53,7 @@ public function handle()
$compose_file_prod_location = "$parent_dir/$compose_file_prod"; $compose_file_prod_location = "$parent_dir/$compose_file_prod";
$install_script_location = "$parent_dir/scripts/install.sh"; $install_script_location = "$parent_dir/scripts/install.sh";
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh"; $upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
$production_env_location = "$parent_dir/.env.production"; $production_env_location = "$parent_dir/.env.production";
$versions_location = "$parent_dir/$versions"; $versions_location = "$parent_dir/$versions";
@ -733,39 +89,22 @@ public function handle()
$compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod"; $compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod";
$production_env_location = "$parent_dir/other/nightly/$production_env"; $production_env_location = "$parent_dir/other/nightly/$production_env";
$upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script"; $upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script";
$upgrade_postgres_script_location = "$parent_dir/other/nightly/$upgrade_postgres_script";
$install_script_location = "$parent_dir/other/nightly/$install_script"; $install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions"; $versions_location = "$parent_dir/other/nightly/$versions";
} }
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { if (! $only_template && ! $only_version) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); $this->info("About to sync $envLabel files to BunnyCDN.");
$this->newLine(); $this->newLine();
// Build file mapping for diff
if ($nightly) {
$fileMapping = [
$compose_file_location => 'docker/nightly/docker-compose.yml',
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
$production_env_location => 'environment/nightly/.env.production',
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
$install_script_location => 'scripts/nightly/install.sh',
];
} else {
$fileMapping = [
$compose_file_location => 'docker/docker-compose.yml',
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
$production_env_location => 'environment/.env.production',
$upgrade_script_location => 'scripts/upgrade.sh',
$install_script_location => 'scripts/install.sh',
];
}
// BunnyCDN file mapping (local file => CDN URL path) // BunnyCDN file mapping (local file => CDN URL path)
$bunnyFileMapping = [ $bunnyFileMapping = [
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
$compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod", $compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
$production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env", $production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
$upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script", $upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
$upgrade_postgres_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script",
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
]; ];
@ -812,44 +151,6 @@ public function handle()
} }
} }
// Diff against GitHub coolify-cdn repo
$this->newLine();
$this->info('Fetching coolify-cdn repo to compare...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
if ($returnCode === 0) {
foreach ($fileMapping as $localFile => $cdnPath) {
$remotePath = "$diffTmpDir/repo/$cdnPath";
if (! file_exists($localFile)) {
continue;
}
if (! file_exists($remotePath)) {
$this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
$hasChanges = true;
continue;
}
$diffOutput = [];
exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
if ($diffCode !== 0) {
$hasChanges = true;
$this->newLine();
$this->info("--- GitHub: $cdnPath");
$this->info("+++ Local: $cdnPath");
foreach ($diffOutput as $line) {
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
continue;
}
$this->line($line);
}
}
}
} else {
$this->warn('Could not fetch coolify-cdn repo for diff.');
}
exec('rm -rf '.escapeshellarg($diffTmpDir)); exec('rm -rf '.escapeshellarg($diffTmpDir));
if (! $hasChanges) { if (! $hasChanges) {
@ -881,9 +182,9 @@ public function handle()
return; return;
} elseif ($only_version) { } elseif ($only_version) {
if ($nightly) { if ($nightly) {
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); $this->info('About to sync NIGHTLY versions.json to BunnyCDN.');
} else { } else {
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
} }
$file = file_get_contents($versions_location); $file = file_get_contents($versions_location);
$json = json_decode($file, true); $json = json_decode($file, true);
@ -891,8 +192,7 @@ public function handle()
$this->info("Version: {$actual_version}"); $this->info("Version: {$actual_version}");
$this->info('This will:'); $this->info('This will:');
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); $this->info(' 1. Sync versions.json to BunnyCDN');
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
$this->newLine(); $this->newLine();
$confirmed = confirm('Are you sure you want to proceed?'); $confirmed = confirm('Are you sure you want to proceed?');
@ -900,8 +200,7 @@ public function handle()
return; return;
} }
// 1. Sync versions.json to BunnyCDN (deprecated but still needed) $this->info('Syncing versions.json to BunnyCDN...');
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [ Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
@ -909,46 +208,8 @@ public function handle()
$this->info('✓ versions.json uploaded & purged to BunnyCDN'); $this->info('✓ versions.json uploaded & purged to BunnyCDN');
$this->newLine(); $this->newLine();
// 2. Create GitHub PR with both releases.json and versions.json
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
if ($githubSuccess) {
$this->info('✓ GitHub PR created successfully with both files');
} else {
$this->error('✗ Failed to create GitHub PR');
}
$this->newLine();
$this->info('=== Summary ==='); $this->info('=== Summary ===');
$this->info('BunnyCDN sync: ✓ Complete'); $this->info('BunnyCDN sync: ✓ Complete');
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
return;
} elseif ($only_github_releases) {
$this->info('About to sync GitHub releases to GitHub repository.');
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
if (! $confirmed) {
return;
}
// Sync releases to GitHub repository
$this->syncReleasesToGitHubRepo();
return;
} elseif ($only_github_versions) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
if (! $confirmed) {
return;
}
// Sync versions.json to GitHub repository
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
return; return;
} }
@ -958,6 +219,7 @@ public function handle()
$pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"), $pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"),
$pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"), $pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"),
$pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"), $pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"),
$pool->storage(fileName: "$upgrade_postgres_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_postgres_script"),
$pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"), $pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"),
]); ]);
Http::pool(fn (Pool $pool) => [ Http::pool(fn (Pool $pool) => [
@ -965,36 +227,14 @@ public function handle()
$pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$production_env"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$production_env"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
]); ]);
$this->info('All files uploaded & purged to BunnyCDN.'); $this->info('All files uploaded & purged to BunnyCDN.');
$this->newLine(); $this->newLine();
// Sync files to GitHub CDN repository via PR
$this->info('Creating GitHub PR for coolify-cdn repository...');
if ($nightly) {
$files = [
$compose_file_location => 'docker/nightly/docker-compose.yml',
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
$production_env_location => 'environment/nightly/.env.production',
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
$install_script_location => 'scripts/nightly/install.sh',
];
} else {
$files = [
$compose_file_location => 'docker/docker-compose.yml',
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
$production_env_location => 'environment/.env.production',
$upgrade_script_location => 'scripts/upgrade.sh',
$install_script_location => 'scripts/install.sh',
];
}
$githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
$this->newLine();
$this->info('=== Summary ==='); $this->info('=== Summary ===');
$this->info('BunnyCDN sync: Complete'); $this->info('BunnyCDN sync: Complete');
$this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage()); $this->error('Error: '.$e->getMessage());
} }

View file

@ -8,6 +8,7 @@
use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Jobs\PullChangelog; use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN; use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob; use App\Jobs\RegenerateSslCertJob;
@ -40,7 +41,10 @@ protected function schedule(Schedule $schedule): void
$this->instanceTimezone = config('app.timezone'); $this->instanceTimezone = config('app.timezone');
} }
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); $this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
->name('cleanup:ssh-mux')
->hourly()
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
@ -78,7 +82,7 @@ protected function schedule(Schedule $schedule): void
// Scheduled Jobs (Backups & Tasks) // Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily(); $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();

View file

@ -4,6 +4,7 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -12,15 +13,13 @@
class SshMultiplexingHelper class SshMultiplexingHelper
{ {
public static function serverSshConfiguration(Server $server) public static function serverSshConfiguration(Server $server): array
{ {
$privateKey = PrivateKey::findOrFail($server->private_key_id); $privateKey = PrivateKey::findOrFail($server->private_key_id);
$sshKeyLocation = $privateKey->getKeyLocation();
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
return [ return [
'sshKeyLocation' => $sshKeyLocation, 'sshKeyLocation' => $privateKey->getKeyLocation(),
'muxFilename' => $muxFilename, 'muxFilename' => self::muxSocket($server),
]; ];
} }
@ -30,40 +29,39 @@ public static function ensureMultiplexedConnection(Server $server): bool
return false; return false;
} }
$sshConfig = self::serverSshConfiguration($server); if (self::connectionIsReusable($server)) {
$muxSocket = $sshConfig['muxFilename']; return true;
// Check if connection exists
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$checkCommand .= self::escapedUserAtHost($server);
$process = Process::run($checkCommand);
if ($process->exitCode() !== 0) {
return self::establishNewMultiplexedConnection($server);
} }
// Connection exists, ensure we have metadata for age tracking try {
if (self::getConnectionAge($server) === null) { return Cache::lock(
// Existing connection but no metadata, store current time as fallback self::connectionLockKey($server),
self::storeConnectionMetadata($server); config('constants.ssh.mux_lock_ttl')
} )->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
if (self::connectionIsReusable($server)) {
return true;
}
// Connection exists, check if it needs refresh due to age if (self::masterConnectionExists($server)) {
if (self::isConnectionExpired($server)) { return self::refreshMultiplexedConnection($server);
return self::refreshMultiplexedConnection($server); }
}
// Perform health check if enabled return self::establishNewMultiplexedConnection($server);
if (config('constants.ssh.mux_health_check_enabled')) { });
if (! self::isConnectionHealthy($server)) { } catch (LockTimeoutException) {
return self::refreshMultiplexedConnection($server); Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
} 'server' => $server->name ?? $server->ip,
} ]);
return true; return false;
} catch (\Throwable $e) {
Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
return false;
}
} }
public static function establishNewMultiplexedConnection(Server $server): bool public static function establishNewMultiplexedConnection(Server $server): bool
@ -75,82 +73,68 @@ public static function establishNewMultiplexedConnection(Server $server): bool
$serverInterval = config('constants.ssh.server_interval'); $serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time'); $muxPersistTime = config('constants.ssh.mux_persist_time');
$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; $establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
} }
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
$establishCommand .= self::escapedUserAtHost($server); $establishCommand .= self::escapedUserAtHost($server);
$establishProcess = Process::run($establishCommand); $establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) { if ($establishProcess->exitCode() !== 0) {
return false; return false;
} }
// Store connection metadata for tracking
self::storeConnectionMetadata($server); self::storeConnectionMetadata($server);
return true; return true;
} }
public static function removeMuxFile(Server $server) public static function removeMuxFile(Server $server): void
{ {
$sshConfig = self::serverSshConfiguration($server); Process::run(self::muxControlCommand($server, 'exit'));
$muxSocket = $sshConfig['muxFilename'];
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$closeCommand .= self::escapedUserAtHost($server);
Process::run($closeCommand);
// Clear connection metadata from cache
self::clearConnectionMetadata($server); self::clearConnectionMetadata($server);
} }
public static function generateScpCommand(Server $server, string $source, string $dest) public static function generateScpCommand(Server $server, string $source, string $dest): string
{ {
$sshConfig = self::serverSshConfiguration($server); $sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation']; $sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename']; $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
$timeout = config('constants.ssh.command_timeout');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$scp_command = "timeout $timeout scp ";
if ($server->isIpv6()) { if ($server->isIpv6()) {
$scp_command .= '-6 '; $scpCommand .= '-6 ';
} }
if (self::isMultiplexingEnabled()) { if (self::isMultiplexingEnabled()) {
try { try {
if (self::ensureMultiplexedConnection($server)) { if (self::ensureMultiplexedConnection($server)) {
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; $scpCommand .= self::multiplexingOptions($server);
} }
} catch (\Exception $e) { } catch (\Throwable $e) {
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [ Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip, 'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
// Continue without multiplexing
} }
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; $scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
} }
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) { if ($server->isIpv6()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
} else {
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
} }
return $scp_command; return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
} }
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false) public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
{ {
if ($server->settings->force_disabled) { if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.'); throw new \RuntimeException('Server is disabled.');
@ -161,40 +145,139 @@ public static function generateSshCommand(Server $server, string $command, bool
self::validateSshKey($server->privateKey); self::validateSshKey($server->privateKey);
$muxSocket = $sshConfig['muxFilename']; $commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
$timeout = config('constants.ssh.command_timeout');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$ssh_command = "timeout $timeout ssh ";
$multiplexingSuccessful = false;
if (! $disableMultiplexing && self::isMultiplexingEnabled()) { if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
try { try {
$multiplexingSuccessful = self::ensureMultiplexedConnection($server); if (self::ensureMultiplexedConnection($server)) {
if ($multiplexingSuccessful) { $sshCommand .= self::multiplexingOptions($server);
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
} }
} catch (\Exception $e) { } catch (\Throwable $e) {
// Continue without multiplexing Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
} }
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; $sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
} }
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); $sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command); $delimiter = base64_encode(Hash::make($command));
$delimiter = base64_encode($delimiter);
$command = str_replace($delimiter, '', $command); $command = str_replace($delimiter, '', $command);
$ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL .$command.PHP_EOL
.$delimiter; .$delimiter;
}
return $ssh_command; public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
return is_numeric($timeout) && (int) $timeout > 0
? (int) $timeout
: (int) config('constants.ssh.connection_timeout');
}
public static function isConnectionHealthy(Server $server): bool
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
$process = Process::run($healthCommand);
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
}
public static function isConnectionExpired(Server $server): bool
{
$connectionAge = self::getConnectionAge($server);
$maxAge = config('constants.ssh.mux_max_age');
return $connectionAge !== null && $connectionAge > $maxAge;
}
public static function getConnectionAge(Server $server): ?int
{
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
if ($connectionTime === null) {
return null;
}
return time() - $connectionTime;
}
public static function refreshMultiplexedConnection(Server $server): bool
{
self::removeMuxFile($server);
return self::establishNewMultiplexedConnection($server);
}
private static function connectionLockKey(Server $server): string
{
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
}
private static function masterConnectionExists(Server $server): bool
{
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
}
private static function connectionIsReusable(Server $server): bool
{
if (! self::masterConnectionExists($server)) {
return false;
}
if (self::getConnectionAge($server) === null) {
self::storeConnectionMetadata($server);
}
if (self::isConnectionExpired($server)) {
return false;
}
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
return false;
}
return true;
}
private static function muxControlCommand(Server $server, string $operation): string
{
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
return $command.self::escapedUserAtHost($server);
}
private static function multiplexingOptions(Server $server): string
{
return '-o ControlMaster=auto '
.'-o ControlPath='.self::muxSocket($server).' '
.'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
}
private static function muxSocket(Server $server): string
{
return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
} }
private static function escapedUserAtHost(Server $server): string private static function escapedUserAtHost(Server $server): string
@ -231,7 +314,6 @@ private static function validateSshKey(PrivateKey $privateKey): void
$privateKey->storeInFileSystem(); $privateKey->storeInFileSystem();
} }
// Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) { if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777; $currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) { if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
@ -243,15 +325,6 @@ private static function validateSshKey(PrivateKey $privateKey): void
} }
} }
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
return is_numeric($timeout) && (int) $timeout > 0
? (int) $timeout
: (int) config('constants.ssh.connection_timeout');
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{ {
$options = "-i {$sshKeyLocation} " $options = "-i {$sshKeyLocation} "
@ -262,90 +335,20 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati
.'-o RequestTTY=no ' .'-o RequestTTY=no '
.'-o LogLevel=ERROR '; .'-o LogLevel=ERROR ';
// Bruh
if ($isScp) { if ($isScp) {
$options .= '-P '.escapeshellarg((string) $server->port).' '; return $options.'-P '.escapeshellarg((string) $server->port).' ';
} else {
$options .= '-p '.escapeshellarg((string) $server->port).' ';
} }
return $options; return $options.'-p '.escapeshellarg((string) $server->port).' ';
} }
/**
* Check if the multiplexed connection is healthy by running a test command
*/
public static function isConnectionHealthy(Server $server): bool
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
$process = Process::run($healthCommand);
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
return $isHealthy;
}
/**
* Check if the connection has exceeded its maximum age
*/
public static function isConnectionExpired(Server $server): bool
{
$connectionAge = self::getConnectionAge($server);
$maxAge = config('constants.ssh.mux_max_age');
return $connectionAge !== null && $connectionAge > $maxAge;
}
/**
* Get the age of the current connection in seconds
*/
public static function getConnectionAge(Server $server): ?int
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
$connectionTime = Cache::get($cacheKey);
if ($connectionTime === null) {
return null;
}
return time() - $connectionTime;
}
/**
* Refresh a multiplexed connection by closing and re-establishing it
*/
public static function refreshMultiplexedConnection(Server $server): bool
{
// Close existing connection
self::removeMuxFile($server);
// Establish new connection
return self::establishNewMultiplexedConnection($server);
}
/**
* Store connection metadata when a new connection is established
*/
private static function storeConnectionMetadata(Server $server): void private static function storeConnectionMetadata(Server $server): void
{ {
$cacheKey = "ssh_mux_connection_time_{$server->uuid}"; Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
} }
/**
* Clear connection metadata from cache
*/
private static function clearConnectionMetadata(Server $server): void private static function clearConnectionMetadata(Server $server): void
{ {
$cacheKey = "ssh_mux_connection_time_{$server->uuid}"; Cache::forget("ssh_mux_connection_time_{$server->uuid}");
Cache::forget($cacheKey);
} }
} }

View file

@ -17,6 +17,7 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Rules\DockerImageFormat;
use App\Rules\ValidGitBranch; use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl; use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
@ -145,7 +146,7 @@ public function applications(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -311,7 +312,7 @@ public function create_public_application(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -477,7 +478,7 @@ public function create_private_gh_app_application(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -780,7 +781,7 @@ public function create_dockerfile_application(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -1023,7 +1024,7 @@ private function create_application(Request $request, $type)
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch], 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
@ -1229,7 +1230,7 @@ private function create_application(Request $request, $type)
'git_repository' => 'string|required', 'git_repository' => 'string|required',
'git_branch' => ['string', 'required', new ValidGitBranch], 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'github_app_uuid' => 'string|required', 'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable', 'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
@ -1469,7 +1470,7 @@ private function create_application(Request $request, $type)
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch], 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'private_key_uuid' => 'string|required', 'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable', 'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
@ -1790,9 +1791,9 @@ private function create_application(Request $request, $type)
]))->setStatusCode(201); ]))->setStatusCode(201);
} elseif ($type === 'dockerimage') { } elseif ($type === 'dockerimage') {
$validationRules = [ $validationRules = [
'docker_registry_image_name' => 'string|required', 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
'docker_registry_image_tag' => 'string', 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules); $validator = customApiValidator($request->all(), $validationRules);

View file

@ -299,6 +299,11 @@ public function database_by_uuid(Request $request)
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
], ],
), ),
) )
@ -565,9 +570,17 @@ public function update_by_uuid(Request $request)
} }
break; break;
} }
$allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
$healthCheckValidator = customApiValidator($request->all(), [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer|min:1',
'health_check_timeout' => 'integer|min:1',
'health_check_retries' => 'integer|min:1',
'health_check_start_period' => 'integer|min:0',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
$errors = $validator->errors(); $errors = $validator->errors()->merge($healthCheckValidator->errors());
if (! empty($extraFields)) { if (! empty($extraFields)) {
foreach ($extraFields as $field) { foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.'); $errors->add($field, 'This field is not allowed.');

View file

@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use Exception;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
class SentinelController extends Controller
{
/**
* Handle a Sentinel agent metrics push.
*
* Sentinel pushes its full container list on a fixed interval (default 60s),
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
* server per minute, the job is only dispatched when the container state hash
* changes, or when the force window has elapsed.
*/
public function push(Request $request)
{
$token = $request->header('Authorization');
if (! $token) {
auditLogWebhookFailure('sentinel', 'token_missing');
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
try {
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
auditLogWebhookFailure('sentinel', 'decrypt_failed');
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
if (! $server_uuid) {
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
return response()->json(['message' => 'Invalid token'], 401);
}
$server = Server::where('uuid', $server_uuid)->first();
if (! $server) {
auditLogWebhookFailure('sentinel', 'server_not_found', [
'server_uuid' => $server_uuid,
]);
return response()->json(['message' => 'Server not found'], 404);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
if ($server->isFunctional() === false) {
auditLogWebhookFailure('sentinel', 'server_not_functional', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Server is not functional'], 401);
}
if ($server->settings->sentinel_token !== $naked_token) {
auditLogWebhookFailure('sentinel', 'token_mismatch', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
$validator = Validator::make($request->all(), [
'containers' => ['present', 'array'],
]);
if ($validator->fails()) {
return response()->json(serializeApiResponse([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
]), 422);
}
$data = $request->all();
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
$server->sentinelHeartbeat();
if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data);
}
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'ok'], 200);
}
/**
* Decide whether PushServerUpdateJob should be dispatched for this push.
*
* Dispatches when: first push (no cached hash), the container state changed,
* or the force window elapsed.
*/
private function shouldDispatchUpdate(Server $server, array $data): bool
{
$hash = $this->containerStateHash($data);
$hashKey = "sentinel:push-hash:{$server->id}";
$forceKey = "sentinel:push-force:{$server->id}";
$lockKey = "sentinel:push-lock:{$server->id}";
try {
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
$cachedHash = Cache::get($hashKey);
$forceActive = Cache::has($forceKey);
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
if ($shouldDispatch) {
// Day-long TTL bounds memory if a server stops pushing entirely.
Cache::put($hashKey, $hash, now()->addDay());
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
}
return $shouldDispatch;
});
} catch (LockTimeoutException) {
return false;
}
}
/**
* Build a stable hash of container state.
*
* Covers [name, state] only metrics, filesystem_usage_root, and
* health_status are excluded on purpose. Disk % churns constantly, and
* health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
* The force window still refreshes full state periodically. Sorted by name
* so container ordering from Sentinel does not affect the hash.
*/
private function containerStateHash(array $data): string
{
$containers = collect(data_get($data, 'containers', []))
->map(fn ($c) => [
'name' => data_get($c, 'name'),
'state' => data_get($c, 'state'),
])
->sortBy('name')
->values()
->all();
return hash('xxh128', json_encode($containers));
}
}

View file

@ -13,6 +13,7 @@
use App\Models\Project; use App\Models\Project;
use App\Models\Server as ModelsServer; use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp; use App\Rules\ValidServerIp;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@ -487,10 +488,12 @@ public function create_server(Request $request)
'ip' => ['string', 'required', new ValidServerIp], 'ip' => ['string', 'required', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535', 'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|required', 'private_key_uuid' => 'string|required',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable', 'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable', 'proxy_type' => 'string|nullable',
], [
...ValidationPatterns::serverUsernameMessages(),
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -666,7 +669,7 @@ public function update_server(Request $request)
'ip' => ['string', 'nullable', new ValidServerIp], 'ip' => ['string', 'nullable', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535', 'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|nullable', 'private_key_uuid' => 'string|nullable',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable', 'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable', 'proxy_type' => 'string|nullable',
@ -676,6 +679,8 @@ public function update_server(Request $request)
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string', 'server_disk_usage_check_frequency' => 'string',
'connection_timeout' => 'integer|min:1|max:300', 'connection_timeout' => 'integer|min:1|max:300',
], [
...ValidationPatterns::serverUsernameMessages(),
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -700,17 +705,17 @@ public function update_server(Request $request)
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) { $validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
return str($proxyType->value)->lower(); return str($proxyType->value)->lower();
}); });
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) { if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
$server->changeProxy($request->proxy_type, async: true);
} else {
return response()->json(['message' => 'Invalid proxy type.'], 422); return response()->json(['message' => 'Invalid proxy type.'], 422);
} }
} }
$server->update($request->only(['name', 'description', 'ip', 'port', 'user'])); $updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
if ($request->is_build_server) { if ($request->filled('private_key_uuid')) {
$server->settings()->update([ $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
'is_build_server' => $request->is_build_server, if (! $privateKey) {
]); return response()->json(['message' => 'Private key not found.'], 404);
}
$updateFields['private_key_id'] = $privateKey->id;
} }
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) { if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
@ -720,11 +725,22 @@ public function update_server(Request $request)
], 422); ], 422);
} }
$server->update($updateFields);
if ($request->has('is_build_server')) {
$server->settings()->update([
'is_build_server' => $request->boolean('is_build_server'),
]);
}
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']); $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) { if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
} }
if ($request->proxy_type) {
$server->changeProxy($request->proxy_type, async: true);
}
if ($request->instant_validate) { if ($request->instant_validate) {
ValidateServer::dispatch($server); ValidateServer::dispatch($server);
} }

View file

@ -7,6 +7,7 @@
use App\Models\User; use App\Models\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -98,23 +99,50 @@ public function link()
{ {
$token = request()->get('token'); $token = request()->get('token');
if ($token) { if ($token) {
$decrypted = Crypt::decryptString($token); try {
$email = str($decrypted)->before('@@@'); $decrypted = Crypt::decryptString($token);
$password = str($decrypted)->after('@@@'); } catch (DecryptException) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
if (! str_contains($decrypted, '@@@')) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} else {
[$email, $password] = $payload;
$invitationUuid = null;
}
$email = Str::lower($email);
$user = User::whereEmail($email)->first(); $user = User::whereEmail($email)->first();
if (! $user) { if (! $user) {
return redirect()->route('login'); return redirect()->route('login');
} }
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if (Hash::check($password, $user->password)) { if (Hash::check($password, $user->password)) {
$invitation = TeamInvitation::whereEmail($email); $team = $invitation->team;
if ($invitation->exists()) { if (! $user->teams()->where('team_id', $team->id)->exists()) {
$team = $invitation->first()->team; $user->teams()->attach($team->id, ['role' => $invitation->role]);
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
$invitation->delete();
} else {
$team = $user->teams()->first();
} }
$invitation->delete();
Auth::login($user); Auth::login($user);
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
session(['currentTeam' => $team]); session(['currentTeam' => $team]);
return redirect()->route('dashboard'); return redirect()->route('dashboard');

View file

@ -5,6 +5,7 @@
use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use Exception; use Exception;
@ -14,6 +15,7 @@
class Bitbucket extends Controller class Bitbucket extends Controller
{ {
use DetectsSkipDeployCommits; use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
@ -62,8 +64,14 @@ public function manual(Request $request)
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash'); $commit = data_get($payload, 'pullrequest.source.commit.hash');
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
$applications = $applications->where('git_branch', $branch)->get(); if ($full_name === null) {
return response([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
}
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response([ return response([
'status' => 'failed', 'status' => 'failed',
@ -79,11 +87,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'event' => $x_bitbucket_event, 'event' => $x_bitbucket_event,
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue; continue;
} }
@ -97,11 +101,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'event' => $x_bitbucket_event, 'event' => $x_bitbucket_event,
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue; continue;
} }
@ -114,11 +114,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'event' => $x_bitbucket_event, 'event' => $x_bitbucket_event,
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue; continue;
} }

View file

@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
use App\Models\Application;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
trait MatchesManualWebhookApplications
{
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
{
if (! is_string($fullName)) {
return null;
}
$fullName = trim($fullName, " \t\n\r\0\x0B/");
if ($fullName === '') {
return null;
}
if (! preg_match('/\A[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\z/', $fullName)) {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($fullName);
}
/**
* @return Collection<int, Application>
*/
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
{
return $query->get()
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
->values();
}
protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
{
$repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
if ($repositoryPath === null) {
return false;
}
// Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
// case-insensitively, so compare the canonical paths case-insensitively.
return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
}
/**
* @return array{status: string, message: string}
*/
protected function unauthenticatedManualWebhookFailurePayload(): array
{
return [
'status' => 'failed',
'message' => 'Invalid signature.',
];
}
protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
{
if (! is_string($gitRepository)) {
return null;
}
$gitRepository = trim($gitRepository);
if ($gitRepository === '') {
return null;
}
$path = null;
$parts = parse_url($gitRepository);
if (is_array($parts) && isset($parts['scheme'])) {
$path = data_get($parts, 'path');
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
$path = Str::after($gitRepository, ':');
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
// Strip the leading numeric port segment so the path matches the webhook
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
} else {
$path = $gitRepository;
}
if (! is_string($path) || $path === '') {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($path);
}
protected function normalizeManualWebhookRepositoryPath(string $path): string
{
$path = trim($path);
$path = strtok($path, '?#') ?: $path;
$path = trim($path, '/');
$path = preg_replace('/\.git\z/i', '', $path) ?? $path;
return $path;
}
}

View file

@ -5,6 +5,7 @@
use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use Exception; use Exception;
@ -15,6 +16,7 @@
class Gitea extends Controller class Gitea extends Controller
{ {
use DetectsSkipDeployCommits; use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
@ -58,15 +60,19 @@ public function manual(Request $request)
if (! $branch) { if (! $branch) {
return response('Nothing to do. No branch found in the request.'); return response('Nothing to do. No branch found in the request.');
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_gitea_event === 'push') { if ($x_gitea_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
} }
} }
if ($x_gitea_event === 'pull_request') { if ($x_gitea_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'."); return response("Nothing to do. No applications found with branch '$base_branch'.");
} }
@ -80,11 +86,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'event' => $x_gitea_event, 'event' => $x_gitea_event,
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue; continue;
} }
@ -96,11 +98,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'event' => $x_gitea_event, 'event' => $x_gitea_event,
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue; continue;
} }

View file

@ -4,13 +4,17 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Jobs\GithubAppPermissionJob; use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook; use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application; use App\Models\Application;
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use Exception; use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@ -18,6 +22,7 @@
class Github extends Controller class Github extends Controller
{ {
use DetectsSkipDeployCommits; use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
@ -59,6 +64,7 @@ public function manual(Request $request)
$before_sha = data_get($payload, 'before'); $before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association'); $author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
} }
if (! in_array($x_github_event, ['push', 'pull_request'])) { if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported."); return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -66,15 +72,19 @@ public function manual(Request $request)
if (! $branch) { if (! $branch) {
return response('Nothing to do. No branch found in the request.'); return response('Nothing to do. No branch found in the request.');
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_github_event === 'push') { if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
} }
} }
if ($x_github_event === 'pull_request') { if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
} }
@ -93,11 +103,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'mode' => 'manual', 'mode' => 'manual',
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue; continue;
} }
@ -109,11 +115,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'mode' => 'manual', 'mode' => 'manual',
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue; continue;
} }
@ -223,6 +225,7 @@ public function manual(Request $request)
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association, authorAssociation: $author_association,
fullName: $full_name, fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
); );
$return_payloads->push([ $return_payloads->push([
@ -304,6 +307,7 @@ public function normal(Request $request)
$before_sha = data_get($payload, 'before'); $before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association'); $author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
} }
if (! in_array($x_github_event, ['push', 'pull_request'])) { if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported."); return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -435,6 +439,7 @@ public function normal(Request $request)
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association, authorAssociation: $author_association,
fullName: $full_name, fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
); );
$return_payloads->push([ $return_payloads->push([
@ -452,55 +457,203 @@ public function normal(Request $request)
} }
} }
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* GitHub's `author_association` is not a reliable trust signal (it grants
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
* detection is gated on whether the PR crosses repository boundaries.
*
* The repository id comparison is the canonical signal; the `head.repo.fork`
* flag and a case-insensitive full_name comparison are fallbacks for payloads
* where the ids are unavailable (e.g. a deleted head repository).
*/
private function isForkPullRequest(mixed $payload): bool
{
$headRepoId = data_get($payload, 'pull_request.head.repo.id');
$baseRepoId = data_get($payload, 'pull_request.base.repo.id');
if ($headRepoId !== null && $baseRepoId !== null) {
return (string) $headRepoId !== (string) $baseRepoId;
}
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
return true;
}
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
}
return false;
}
public function redirect(Request $request) public function redirect(Request $request)
{ {
try { $code = (string) $request->query('code', '');
$code = $request->get('code'); abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
$state = $request->get('state');
$github_app = GithubApp::where('uuid', $state)->firstOrFail();
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); $github_app = $this->consumeGithubAppSetupState(
} catch (Exception $e) { request: $request,
return handleError($e); state: (string) $request->query('state', ''),
} action: 'manifest',
);
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)
->accept('application/vnd.github+json')
->timeout(10)
->connectTimeout(5)
->post("$api_url/app-manifests/$code/conversions")
->throw()
->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} }
public function install(Request $request) public function install(Request $request)
{ {
try { $setup_action = (string) $request->query('setup_action', '');
$installation_id = $request->get('installation_id'); abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
$source = $request->get('source');
$setup_action = $request->get('setup_action');
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
if ($setup_action === 'install') {
$github_app->installation_id = $installation_id;
$github_app->save();
}
$installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
if ($setup_action === 'update') {
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'install',
);
abort_unless(
$this->githubInstallationBelongsToApp($github_app, $installation_id),
403,
'GitHub App installation could not be verified.'
);
$github_app->installation_id = $installation_id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
{
$github_app = GithubApp::ownedByCurrentTeam()
->where('installation_id', $installation_id)
->first();
if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (Exception $e) { }
return handleError($e);
return redirect()->route('source.all');
}
/**
* Verify that the given installation id actually belongs to this GitHub App.
*
* The installation id arrives as an untrusted query parameter on an
* unauthenticated-reachable GET callback, so it must be confirmed against
* the GitHub API using the App's own credentials before it is persisted.
*/
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
{
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
return false;
}
try {
$jwt = generateGithubJwt($github_app);
$response = Http::withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->connectTimeout(5)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
return $response->successful()
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
} catch (\Throwable) {
return false;
} }
} }
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{
if (blank($state)) {
$this->rejectInvalidGithubAppSetupState($request);
}
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
$this->rejectInvalidGithubAppSetupState($request);
}
$team_id = $request->user()?->currentTeam()?->id;
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', data_get($payload, 'team_id'))
->firstOrFail();
}
private function rejectInvalidGithubAppSetupState(Request $request): never
{
if ($request->expectsJson()) {
abort(404);
}
throw new HttpResponseException(
redirect()
->route('source.all')
);
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
{
return filled($github_app->app_id)
|| filled($github_app->client_id)
|| filled($github_app->client_secret)
|| filled($github_app->webhook_secret)
|| filled($github_app->private_key_id);
}
} }

View file

@ -5,6 +5,7 @@
use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use Exception; use Exception;
@ -15,6 +16,7 @@
class Gitlab extends Controller class Gitlab extends Controller
{ {
use DetectsSkipDeployCommits; use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
@ -85,9 +87,18 @@ public function manual(Request $request)
return response($return_payloads); return response($return_payloads);
} }
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
return response($return_payloads);
}
$applications = Application::query();
if ($x_gitlab_event === 'push') { if ($x_gitlab_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
@ -98,7 +109,7 @@ public function manual(Request $request)
} }
} }
if ($x_gitlab_event === 'merge_request') { if ($x_gitlab_event === 'merge_request') {
$applications = $applications->where('git_branch', $base_branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
@ -117,11 +128,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'event' => $x_gitlab_event, 'event' => $x_gitlab_event,
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue; continue;
} }
@ -132,11 +139,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
'event' => $x_gitlab_event, 'event' => $x_gitlab_event,
]); ]);
$return_payloads->push([ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue; continue;
} }

View file

@ -12,6 +12,7 @@
use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled; use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrimStrings;
@ -104,6 +105,7 @@ class Kernel extends HttpKernel
'ability' => CheckForAnyAbility::class, 'ability' => CheckForAnyAbility::class,
'api.ability' => ApiAbility::class, 'api.ability' => ApiAbility::class,
'api.sensitive' => ApiSensitiveData::class, 'api.sensitive' => ApiSensitiveData::class,
'api.token.team' => EnsureTokenBelongsToCurrentTeamMember::class,
'can.create.resources' => CanCreateResources::class, 'can.create.resources' => CanCreateResources::class,
'can.update.resource' => CanUpdateResource::class, 'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class, 'can.access.terminal' => CanAccessTerminal::class,

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTokenBelongsToCurrentTeamMember
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$token = $user?->currentAccessToken();
$teamId = $token?->team_id;
if (! $user || ! $token || is_null($teamId)) {
return response()->json(['message' => 'Invalid token.'], 401);
}
$team = $user->teams()
->where('teams.id', $teamId)
->first();
if (! $team) {
return response()->json(['message' => 'Invalid token.'], 401);
}
$role = $team->pivot?->role;
if (($token->can('root') || $token->can('write') || $token->can('write:sensitive'))
&& ! in_array($role, ['admin', 'owner'], true)) {
return response()->json(['message' => 'Missing required team role.'], 403);
}
return $next($request);
}
}

View file

@ -197,7 +197,7 @@ public function tags()
public function __construct(public int $application_deployment_queue_id) public function __construct(public int $application_deployment_queue_id)
{ {
$this->onQueue('high'); $this->onQueue(deployment_queue());
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id); $this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
$this->nixpacks_plan_json = collect([]); $this->nixpacks_plan_json = collect([]);
@ -220,6 +220,7 @@ public function __construct(public int $application_deployment_queue_id)
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile'; $this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
$this->only_this_server = $this->application_deployment_queue->only_this_server; $this->only_this_server = $this->application_deployment_queue->only_this_server;
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
$this->validateDockerRegistryImageConfiguration();
$this->git_type = data_get($this->application_deployment_queue, 'git_type'); $this->git_type = data_get($this->application_deployment_queue, 'git_type');
@ -1106,7 +1107,7 @@ private function push_to_docker_registry()
'hidden' => true, 'hidden' => true,
], ],
); );
if ($this->application->docker_registry_image_tag) { if ($this->shouldPushDockerRegistryImageTag()) {
// Tag image with docker_registry_image_tag // Tag image with docker_registry_image_tag
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag."); $this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
$this->execute_remote_command( $this->execute_remote_command(
@ -1130,6 +1131,30 @@ private function push_to_docker_registry()
} }
} }
private function shouldPushDockerRegistryImageTag(): bool
{
if (blank($this->application->docker_registry_image_tag)) {
return false;
}
return $this->pull_request_id === 0;
}
private function validateDockerRegistryImageConfiguration(): void
{
if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) {
throw new DeploymentException('Docker registry image name contains invalid characters.');
}
if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) {
throw new DeploymentException('Docker registry image tag contains invalid characters.');
}
if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) {
throw new DeploymentException('Docker registry preview image tag contains invalid characters.');
}
}
private function generate_image_names() private function generate_image_names()
{ {
if ($this->application->dockerfile) { if ($this->application->dockerfile) {
@ -1293,12 +1318,8 @@ private function generate_runtime_environment_variables()
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id'); $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
} }
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
});
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
});
} }
$ports = $this->application->main_port(); $ports = $this->application->main_port();
$coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs = $this->generate_coolify_env_variables();
@ -1367,7 +1388,7 @@ private function generate_runtime_environment_variables()
// Add PORT if not exists, use the first port as default // Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') { if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) {
$envs->push("PORT={$ports[0]}"); $envs->push("PORT={$ports[0]}");
} }
} }
@ -1451,6 +1472,15 @@ private function generate_runtime_environment_variables()
return $envs; return $envs;
} }
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
{
$key = str($environmentVariable->key);
return $key->startsWith('SERVICE_FQDN_')
|| $key->startsWith('SERVICE_URL_')
|| $key->startsWith('SERVICE_NAME_');
}
private function save_runtime_environment_variables() private function save_runtime_environment_variables()
{ {
// This method saves the .env file with ALL runtime variables // This method saves the .env file with ALL runtime variables
@ -1666,11 +1696,9 @@ private function generate_buildtime_environment_variables()
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these // For Docker Compose, filter out generated SERVICE_* variables as we generate these
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
@ -1719,11 +1747,9 @@ private function generate_buildtime_environment_variables()
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
@ -2103,21 +2129,23 @@ private function prepare_builder_image(bool $firstTry = true)
$helperImage = "{$helperImage}:".getHelperVersion(); $helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory // Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets(); $env_flags = $this->generate_docker_env_flags_for_secrets();
$buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
if ($this->use_build_server) { if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') { if ($this->dockerConfigFileExists === 'NOK') {
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
} }
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else { } else {
if ($this->dockerConfigFileExists === 'OK') { if ($this->dockerConfigFileExists === 'OK') {
$safeNetwork = escapeshellarg($this->destination->network); $safeNetwork = escapeshellarg($this->destination->network);
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else { } else {
$safeNetwork = escapeshellarg($this->destination->network); $safeNetwork = escapeshellarg($this->destination->network);
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} }
} }
if ($firstTry) { if ($firstTry) {
@ -2222,11 +2250,22 @@ private function set_coolify_variables()
} }
} }
if (isset($this->application->git_branch)) { if (isset($this->application->git_branch)) {
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
} }
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
} }
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
{
$sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
if ($identityFile !== null) {
$sshCommand .= " -i {$identityFile} -o IdentitiesOnly=yes";
}
return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
}
private function check_git_if_build_needed() private function check_git_if_build_needed()
{ {
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
@ -2261,18 +2300,19 @@ private function check_git_if_build_needed()
$private_key = data_get($this->application, 'private_key.private_key'); $private_key = data_get($this->application, 'private_key.private_key');
if ($private_key) { if ($private_key) {
$private_key = base64_encode($private_key); $private_key = base64_encode($private_key);
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
$this->execute_remote_command( $this->execute_remote_command(
[ [
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'), executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
], ],
[ [
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
], ],
[ [
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
], ],
[ [
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
'hidden' => true, 'hidden' => true,
'save' => 'git_commit_sha', 'save' => 'git_commit_sha',
] ]
@ -2280,7 +2320,7 @@ private function check_git_if_build_needed()
} else { } else {
$this->execute_remote_command( $this->execute_remote_command(
[ [
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
'hidden' => true, 'hidden' => true,
'save' => 'git_commit_sha', 'save' => 'git_commit_sha',
], ],
@ -3019,6 +3059,10 @@ private function generate_env_variables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) { foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) { if (! is_null($resolvedValue)) {
@ -3031,6 +3075,10 @@ private function generate_env_variables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) { foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) { if (! is_null($resolvedValue)) {
@ -3091,7 +3139,7 @@ private function generate_compose_file()
'image' => $this->production_image_name, 'image' => $this->production_image_name,
'container_name' => $this->container_name, 'container_name' => $this->container_name,
'restart' => RESTART_MODE, 'restart' => RESTART_MODE,
'expose' => $ports, ...(! empty($ports) ? ['expose' => $ports] : []),
'networks' => [ 'networks' => [
$this->destination->network => [ $this->destination->network => [
'aliases' => array_merge( 'aliases' => array_merge(
@ -3123,16 +3171,19 @@ private function generate_compose_file()
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
// If healthcheck is disabled, no healthcheck will be added // If healthcheck is disabled, no healthcheck will be added
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) { if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
$docker_compose['services'][$this->container_name]['healthcheck'] = [ $healthcheck_command = $this->generate_healthcheck_commands();
'test' => [ if ($healthcheck_command !== null) {
'CMD-SHELL', $docker_compose['services'][$this->container_name]['healthcheck'] = [
$this->generate_healthcheck_commands(), 'test' => [
], 'CMD-SHELL',
'interval' => $this->application->health_check_interval.'s', $healthcheck_command,
'timeout' => $this->application->health_check_timeout.'s', ],
'retries' => $this->application->health_check_retries, 'interval' => $this->application->health_check_interval.'s',
'start_period' => $this->application->health_check_start_period.'s', 'timeout' => $this->application->health_check_timeout.'s',
]; 'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
}
} }
if (! is_null($this->application->limits_cpuset)) { if (! is_null($this->application->limits_cpuset)) {
@ -3342,7 +3393,11 @@ private function generate_healthcheck_commands()
// HTTP type healthcheck (default) // HTTP type healthcheck (default)
if (! $this->application->health_check_port) { if (! $this->application->health_check_port) {
$health_check_port = (int) $this->application->ports_exposes_array[0]; if (! empty($this->application->ports_exposes_array)) {
$health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
return null;
}
} else { } else {
$health_check_port = (int) $this->application->health_check_port; $health_check_port = (int) $this->application->health_check_port;
} }

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -20,6 +21,132 @@ public function handle()
{ {
$this->cleanupStaleConnections(); $this->cleanupStaleConnections();
$this->cleanupNonExistentServerConnections(); $this->cleanupNonExistentServerConnections();
$this->cleanupOrphanedSshProcesses();
$this->cleanupOrphanedCloudflaredProcesses();
}
/**
* Kill backgrounded ssh master processes that lost the ControlPath socket
* race. Such processes are not masters, so ControlPersist never reaps them
* and they leak memory until the container restarts. A legitimate master
* always owns its socket file; an orphan has none.
*
* Processes younger than the minimum age are skipped: a freshly forked
* master creates its socket a few milliseconds after starting, so a young
* process with no socket may simply be mid-establish rather than orphaned.
*/
private function cleanupOrphanedSshProcesses(): void
{
$muxDir = storage_path('app/ssh/mux');
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
foreach ($this->listProcesses() as $process) {
// Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`.
if (! preg_match('#(^|/)ssh -fN#', $process['args'])) {
continue;
}
// Only ever touch ssh processes pointing at Coolify's mux directory.
if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) {
continue;
}
if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) {
$this->reapOrphan('ssh', $process);
}
}
}
/**
* Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
* as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
* die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
* mux master), the cloudflared process can leak and accumulate. A legitimate
* proxy always has a live ssh parent; one without is safe to reap.
*
* Processes younger than the minimum age are skipped so a proxy whose parent
* ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
* never mistaken for an orphan.
*/
private function cleanupOrphanedCloudflaredProcesses(): void
{
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
$processes = $this->listProcesses();
$sshPids = [];
foreach ($processes as $process) {
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
$sshPids[$process['pid']] = true;
}
}
foreach ($processes as $process) {
// `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
if (! str_contains($process['args'], 'cloudflared access ssh')) {
continue;
}
// Orphaned when no live ssh process is its parent.
if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
$this->reapOrphan('cloudflared', $process);
}
}
}
/**
* Reap a detected orphan process. When orphan reaping is disabled (the
* default), the orphan is only logged a dry-run mode that lets operators
* verify what would be killed before enabling it for real.
*
* @param array{pid: string, ppid: string, etimes: int, args: string} $process
*/
private function reapOrphan(string $kind, array $process): void
{
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
'pid' => $process['pid'],
'etimes' => $process['etimes'],
'command' => $process['args'],
]);
return;
}
Process::run('kill '.escapeshellarg($process['pid']));
Log::info("Killed orphaned {$kind} process", [
'pid' => $process['pid'],
'etimes' => $process['etimes'],
'command' => $process['args'],
]);
}
/**
* Snapshot of running processes.
*
* @return list<array{pid: string, ppid: string, etimes: int, args: string}>
*/
private function listProcesses(): array
{
$ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
if ($ps->exitCode() !== 0) {
return [];
}
$processes = [];
foreach (explode("\n", trim($ps->output())) as $line) {
if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
continue;
}
$processes[] = [
'pid' => $matches[1],
'ppid' => $matches[2],
'etimes' => (int) $matches[3],
'args' => $matches[4],
];
}
return $processes;
} }
private function cleanupStaleConnections() private function cleanupStaleConnections()
@ -31,7 +158,7 @@ private function cleanupStaleConnections()
$server = Server::where('uuid', $serverUuid)->first(); $server = Server::where('uuid', $serverUuid)->first();
if (! $server) { if (! $server) {
$this->removeMultiplexFile($muxFile); $this->removeMultiplexFile($muxFile, 'server_not_found');
continue; continue;
} }
@ -41,14 +168,14 @@ private function cleanupStaleConnections()
$checkProcess = Process::run($checkCommand); $checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) { if ($checkProcess->exitCode() !== 0) {
$this->removeMultiplexFile($muxFile); $this->removeMultiplexFile($muxFile, 'connection_check_failed');
} else { } else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile); $muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37)); $establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) { if (Carbon::now()->isAfter($expirationTime)) {
$this->removeMultiplexFile($muxFile); $this->removeMultiplexFile($muxFile, 'expired');
} }
} }
} }
@ -62,7 +189,7 @@ private function cleanupNonExistentServerConnections()
foreach ($muxFiles as $muxFile) { foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile); $serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
if (! in_array($serverUuid, $existingServerUuids)) { if (! in_array($serverUuid, $existingServerUuids)) {
$this->removeMultiplexFile($muxFile); $this->removeMultiplexFile($muxFile, 'server_does_not_exist');
} }
} }
} }
@ -72,11 +199,30 @@ private function extractServerUuidFromMuxFile($muxFile)
return substr($muxFile, 4); return substr($muxFile, 4);
} }
private function removeMultiplexFile($muxFile) /**
* Close and delete a stale mux socket file. When orphan reaping is disabled
* (the default), the file is only logged a dry-run mode that lets operators
* verify what would be removed before enabling it for real.
*/
private function removeMultiplexFile(string $muxFile, string $reason): void
{ {
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
Log::info('Stale mux file detected (dry-run, not removed)', [
'file' => $muxFile,
'reason' => $reason,
]);
return;
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
Process::run($closeCommand); Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFile); Storage::disk('ssh-mux')->delete($muxFile);
Log::info('Removed stale mux file', [
'file' => $muxFile,
'reason' => $reason,
]);
} }
} }

View file

@ -77,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup) public function __construct(public ScheduledDatabaseBackup $backup)
{ {
$this->onQueue('high'); $this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600; $this->timeout = $backup->timeout ?? 3600;
} }
@ -668,12 +668,14 @@ private function calculate_size()
private function upload_to_s3(): void private function upload_to_s3(): void
{ {
if (is_null($this->s3)) { if (is_null($this->s3)) {
$previousS3StorageId = $this->backup->s3_storage_id;
$this->backup->update([ $this->backup->update([
'save_s3' => false, 'save_s3' => false,
's3_storage_id' => null, 's3_storage_id' => null,
]); ]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
} }
try { try {

View file

@ -39,6 +39,7 @@ public function __construct(
public string $commitSha, public string $commitSha,
public ?string $authorAssociation, public ?string $authorAssociation,
public string $fullName, public string $fullName,
public bool $isForkPullRequest = false,
) { ) {
$this->onQueue('high'); $this->onQueue('high');
} }
@ -92,7 +93,17 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
// Check if PR deployments from public contributors are restricted // Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) { if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; // Fork PRs carry untrusted code from a repository outside our control.
// GitHub's author_association cannot be trusted to gate these (it grants
// CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
// PRs are never deployed automatically when public previews are off.
if ($this->isForkPullRequest) {
return;
}
// Same-repo (non-fork) branch PRs require push access to the base repo,
// so only trusted associations are allowed to trigger a deployment.
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (! in_array($this->authorAssociation, $trustedAssociations)) { if (! in_array($this->authorAssociation, $trustedAssociations)) {
return; return;
} }

View file

@ -13,6 +13,16 @@
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use App\Notifications\Container\ContainerRestarted; use App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator; use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus; use App\Traits\CalculatesExcludedStatus;
@ -25,6 +35,7 @@
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Laravel\Horizon\Contracts\Silenced; use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $services; public Collection $services;
public Collection $applicationsById;
public Collection $previewsByKey;
public Collection $databasesByUuid;
public Collection $servicesById;
public Collection $serviceApplicationsById;
public Collection $serviceDatabasesById;
public Collection $allApplicationIds; public Collection $allApplicationIds;
public Collection $allDatabaseUuids; public Collection $allDatabaseUuids;
@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public bool $foundLogDrainContainer = false; public bool $foundLogDrainContainer = false;
private ?array $cachedDestinationIds = null;
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()]; return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
@ -103,6 +128,12 @@ public function __construct(public Server $server, public $data)
$this->allTcpProxyUuids = collect(); $this->allTcpProxyUuids = collect();
$this->allServiceApplicationIds = collect(); $this->allServiceApplicationIds = collect();
$this->allServiceDatabaseIds = collect(); $this->allServiceDatabaseIds = collect();
$this->applicationsById = collect();
$this->previewsByKey = collect();
$this->databasesByUuid = collect();
$this->servicesById = collect();
$this->serviceApplicationsById = collect();
$this->serviceDatabasesById = collect();
} }
public function handle() public function handle()
@ -120,6 +151,16 @@ public function handle()
$this->allTcpProxyUuids ??= collect(); $this->allTcpProxyUuids ??= collect();
$this->allServiceApplicationIds ??= collect(); $this->allServiceApplicationIds ??= collect();
$this->allServiceDatabaseIds ??= collect(); $this->allServiceDatabaseIds ??= collect();
$this->applicationsById ??= collect();
$this->previewsByKey ??= collect();
$this->databasesByUuid ??= collect();
$this->servicesById ??= collect();
$this->serviceApplicationsById ??= collect();
$this->serviceDatabasesById ??= collect();
// Eager-load relations the job touches repeatedly to avoid lazy-load queries
// (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications).
$this->server->loadMissing(['settings', 'team']);
// TODO: Swarm is not supported yet // TODO: Swarm is not supported yet
if (! $this->data) { if (! $this->data) {
@ -127,30 +168,40 @@ public function handle()
} }
$data = collect($this->data); $data = collect($this->data);
$this->server->sentinelHeartbeat(); // Heartbeat is updated by SentinelController on every push, before dispatch.
$this->containers = collect(data_get($data, 'containers')); $this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
// Only dispatch storage check when disk percentage actually changes // Only dispatch the storage check when disk usage is at/above the notification
// threshold AND the value changed. Below the threshold ServerStorageCheckJob
// has nothing to do (it only sends a HighDiskUsage notification), so dispatching
// it is wasted work — and most servers sit well below the threshold.
$diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80);
$storageCacheKey = 'storage-check:'.$this->server->id; $storageCacheKey = 'storage-check:'.$this->server->id;
$lastPercentage = Cache::get($storageCacheKey); $lastPercentage = Cache::get($storageCacheKey);
if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) { if ($filesystemUsageRoot !== null
&& $filesystemUsageRoot >= $diskThreshold
&& (string) $lastPercentage !== (string) $filesystemUsageRoot) {
Cache::put($storageCacheKey, $filesystemUsageRoot, 600); Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
} elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) {
Cache::forget($storageCacheKey);
} }
if ($this->containers->isEmpty()) { if ($this->containers->isEmpty()) {
return; return;
} }
$this->applications = $this->server->applications(); $this->applications = $this->loadApplications();
$this->databases = $this->server->databases(); $this->databases = $this->loadDatabases();
$this->previews = $this->server->previews(); $this->previews = $this->loadPreviews();
// Eager load service applications and databases to avoid N+1 queries $this->services = $this->loadServices();
$this->services = $this->server->services() $this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id);
->with(['applications:id,service_id', 'databases:id,service_id']) $this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id);
->get(); $this->databasesByUuid = $this->databases->keyBy('uuid');
$this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id);
$this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id);
$this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id);
$this->allApplicationIds = $this->applications->filter(function ($application) { $this->allApplicationIds = $this->applications->filter(function ($application) {
return $application->additional_servers_count === 0; return $application->additional_servers_count === 0;
@ -163,9 +214,8 @@ public function handle()
}); });
$this->allDatabaseUuids = $this->databases->pluck('uuid'); $this->allDatabaseUuids = $this->databases->pluck('uuid');
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid'); $this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
// Use eager-loaded relationships instead of querying in loop $this->allServiceApplicationIds = $this->serviceApplicationsById->keys();
$this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id')); $this->allServiceDatabaseIds = $this->serviceDatabasesById->keys();
$this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
foreach ($this->containers as $container) { foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited'); $containerStatus = data_get($container, 'state', 'exited');
@ -279,6 +329,151 @@ public function handle()
$this->checkLogDrainContainer(); $this->checkLogDrainContainer();
} }
private function loadApplications(): Collection
{
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
$applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty())
? Application::withoutGlobalScope('withRelations')
->select([
'id',
'uuid',
'name',
'status',
'build_pack',
'docker_compose_raw',
'destination_id',
'destination_type',
'last_online_at',
])
->withCount('additional_servers')
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
->get()
: collect();
$additionalApplicationIds = DB::table('additional_destinations')
->where('server_id', $this->server->id)
->pluck('application_id');
if ($additionalApplicationIds->isNotEmpty()) {
$applications = $applications->concat(
Application::withoutGlobalScope('withRelations')
->select([
'id',
'uuid',
'name',
'status',
'build_pack',
'docker_compose_raw',
'destination_id',
'destination_type',
'last_online_at',
])
->withCount('additional_servers')
->whereIn('id', $additionalApplicationIds)
->get()
);
}
return $applications->unique('id')->values();
}
private function loadPreviews(): Collection
{
$applicationIds = $this->applications->pluck('id');
if ($applicationIds->isEmpty()) {
return collect();
}
return ApplicationPreview::query()
->select([
'id',
'application_id',
'pull_request_id',
'status',
'last_online_at',
])
->whereIn('application_id', $applicationIds)
->get();
}
private function loadServices(): Collection
{
return $this->server->services()
->select([
'id',
'server_id',
'uuid',
'docker_compose_raw',
])
->with([
'applications:id,service_id,status,last_online_at',
'databases:id,service_id,status,last_online_at,is_public,name',
])
->get();
}
private function loadDatabases(): Collection
{
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) {
return collect();
}
$databaseColumns = [
'id',
'uuid',
'name',
'status',
'is_public',
'destination_id',
'destination_type',
'last_online_at',
'restart_count',
'last_restart_at',
'last_restart_type',
];
return collect([
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMongodb::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) {
return $databaseClass::query()
->select($databaseColumns)
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
->get();
})->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values();
}
private function serverDestinationIds(): array
{
if ($this->cachedDestinationIds !== null) {
return $this->cachedDestinationIds;
}
return $this->cachedDestinationIds = [
StandaloneDocker::where('server_id', $this->server->id)->pluck('id'),
SwarmDocker::where('server_id', $this->server->id)->pluck('id'),
];
}
private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void
{
$query->where(function ($query) use ($standaloneDockerIds) {
$query->where('destination_type', StandaloneDocker::class)
->whereIn('destination_id', $standaloneDockerIds);
})->orWhere(function ($query) use ($swarmDockerIds) {
$query->where('destination_type', SwarmDocker::class)
->whereIn('destination_id', $swarmDockerIds);
});
}
private function aggregateMultiContainerStatuses() private function aggregateMultiContainerStatuses()
{ {
if ($this->applicationContainerStatuses->isEmpty()) { if ($this->applicationContainerStatuses->isEmpty()) {
@ -286,7 +481,7 @@ private function aggregateMultiContainerStatuses()
} }
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applicationsById->get((string) $applicationId);
if (! $application) { if (! $application) {
continue; continue;
} }
@ -307,8 +502,6 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) { if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus; $application->status = $aggregatedStatus;
$application->save(); $application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
} }
continue; continue;
@ -323,8 +516,6 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) { if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus; $application->status = $aggregatedStatus;
$application->save(); $application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
} }
} }
} }
@ -343,7 +534,7 @@ private function aggregateServiceContainerStatuses()
continue; continue;
} }
$service = $this->services->where('id', $serviceId)->first(); $service = $this->servicesById->get((string) $serviceId);
if (! $service) { if (! $service) {
continue; continue;
} }
@ -351,9 +542,9 @@ private function aggregateServiceContainerStatuses()
// Get the service sub-resource (ServiceApplication or ServiceDatabase) // Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null; $subResource = null;
if ($subType === 'application') { if ($subType === 'application') {
$subResource = $service->applications->where('id', $subId)->first(); $subResource = $this->serviceApplicationsById->get((string) $subId);
} elseif ($subType === 'database') { } elseif ($subType === 'database') {
$subResource = $service->databases->where('id', $subId)->first(); $subResource = $this->serviceDatabasesById->get((string) $subId);
} }
if (! $subResource) { if (! $subResource) {
@ -375,8 +566,6 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus; $subResource->status = $aggregatedStatus;
$subResource->save(); $subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
} }
continue; continue;
@ -392,39 +581,31 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus; $subResource->status = $aggregatedStatus;
$subResource->save(); $subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
} }
} }
} }
private function updateApplicationStatus(string $applicationId, string $containerStatus) private function updateApplicationStatus(string $applicationId, string $containerStatus)
{ {
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applicationsById->get((string) $applicationId);
if (! $application) { if (! $application) {
return; return;
} }
if ($application->status !== $containerStatus) { if ($application->status !== $containerStatus) {
$application->status = $containerStatus; $application->status = $containerStatus;
$application->save(); $application->save();
} else {
$application->update(['last_online_at' => now()]);
} }
} }
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus) private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
{ {
$application = $this->previews->where('application_id', $applicationId) $application = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
->where('pull_request_id', $pullRequestId)
->first();
if (! $application) { if (! $application) {
return; return;
} }
if ($application->status !== $containerStatus) { if ($application->status !== $containerStatus) {
$application->status = $containerStatus; $application->status = $containerStatus;
$application->save(); $application->save();
} else {
$application->update(['last_online_at' => now()]);
} }
} }
@ -472,9 +653,7 @@ private function updateNotFoundApplicationPreviewStatus()
$applicationId = $parts[0]; $applicationId = $parts[0];
$pullRequestId = $parts[1]; $pullRequestId = $parts[1];
$applicationPreview = $this->previews->where('application_id', $applicationId) $applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
->where('pull_request_id', $pullRequestId)
->first();
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) { if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
$previewIdsToUpdate->push($applicationPreview->id); $previewIdsToUpdate->push($applicationPreview->id);
@ -500,11 +679,11 @@ private function updateProxyStatus()
} catch (\Throwable $e) { } catch (\Throwable $e) {
} }
} else { } else {
// Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches. // Connect proxy to networks periodically as a safety net to avoid excessive job dispatches.
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this. // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
$proxyCacheKey = 'connect-proxy:'.$this->server->id; $proxyCacheKey = 'connect-proxy:'.$this->server->id;
if (! Cache::has($proxyCacheKey)) { if (! Cache::has($proxyCacheKey)) {
Cache::put($proxyCacheKey, true, 600); Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600));
ConnectProxyToNetworksJob::dispatch($this->server); ConnectProxyToNetworksJob::dispatch($this->server);
} }
} }
@ -513,15 +692,13 @@ private function updateProxyStatus()
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
{ {
$database = $this->databases->where('uuid', $databaseUuid)->first(); $database = $this->databasesByUuid->get($databaseUuid);
if (! $database) { if (! $database) {
return; return;
} }
if ($database->status !== $containerStatus) { if ($database->status !== $containerStatus) {
$database->status = $containerStatus; $database->status = $containerStatus;
$database->save(); $database->save();
} else {
$database->update(['last_online_at' => now()]);
} }
if ($this->isRunning($containerStatus) && $tcpProxy) { if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
@ -556,7 +733,7 @@ private function updateNotFoundDatabaseStatus()
} }
$notFoundDatabaseUuids->each(function ($databaseUuid) { $notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first(); $database = $this->databasesByUuid->get($databaseUuid);
if ($database) { if ($database) {
if (! str($database->status)->startsWith('exited')) { if (! str($database->status)->startsWith('exited')) {
$database->update([ $database->update([

View file

@ -6,14 +6,15 @@
use App\Models\ScheduledTask; use App\Models\ScheduledTask;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CHUNK_SIZE = 100;
/** /**
* The time when this job execution started. * The time when this job execution started.
* Used to ensure all scheduled items are evaluated against the same point in time. * Used to ensure all scheduled items are evaluated against the same point in time.
@ -37,17 +40,7 @@ class ScheduledJobManager implements ShouldQueue
*/ */
public function __construct() public function __construct()
{ {
$this->onQueue($this->determineQueue()); $this->onQueue(crons_queue());
}
private function determineQueue(): string
{
$preferredQueue = 'crons';
$fallbackQueue = 'high';
$configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
} }
/** /**
@ -106,21 +99,11 @@ public function handle(): void
'execution_time' => $this->executionTime->toIso8601String(), 'execution_time' => $this->executionTime->toIso8601String(),
]); ]);
// Process backups - don't let failures stop task processing // Process scheduled backups and tasks together so neither type starves the other.
try { try {
$this->processScheduledBackups(); $this->processScheduledBackupsAndTasks();
} catch (\Exception $e) { } catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [ Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
// Process tasks - don't let failures stop the job manager
try {
$this->processScheduledTasks();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
@ -151,125 +134,211 @@ public function handle(): void
} }
} }
private function processScheduledBackups(): void private function processScheduledBackupsAndTasks(): void
{ {
$backups = ScheduledDatabaseBackup::with(['database']) $lastBackupId = 0;
$lastTaskId = 0;
do {
$backups = $this->scheduledBackupQuery($lastBackupId)->get();
$tasks = $this->scheduledTaskQuery($lastTaskId)->get();
if ($backups->isNotEmpty()) {
$lastBackupId = $backups->last()->id;
}
if ($tasks->isNotEmpty()) {
$lastTaskId = $tasks->last()->id;
}
$this->processInterleavedDueSchedules(
$this->dueScheduledBackups($backups),
$this->dueScheduledTasks($tasks),
);
} while ($backups->isNotEmpty() || $tasks->isNotEmpty());
}
/**
* @param array<int, array{backup: ScheduledDatabaseBackup, server: Server}> $dueBackups
* @param array<int, array{task: ScheduledTask, server: Server}> $dueTasks
*/
private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void
{
$maxCount = max(count($dueBackups), count($dueTasks));
for ($index = 0; $index < $maxCount; $index++) {
if (isset($dueBackups[$index])) {
$this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']);
}
if (isset($dueTasks[$index])) {
$this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']);
}
}
}
private function scheduledBackupQuery(int $lastBackupId): Builder
{
return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
->where('enabled', true) ->where('enabled', true)
->get(); ->where('id', '>', $lastBackupId)
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
private function scheduledTaskQuery(int $lastTaskId): Builder
{
return ScheduledTask::with([
'service.destination.server.settings',
'service.destination.server.team.subscription',
'application.destination.server.settings',
'application.destination.server.team.subscription',
])
->where('enabled', true)
->where('id', '>', $lastTaskId)
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
/**
* @param iterable<ScheduledDatabaseBackup> $backups
* @return array<int, array{backup: ScheduledDatabaseBackup, server: Server}>
*/
private function dueScheduledBackups(iterable $backups): array
{
$dueBackups = [];
foreach ($backups as $backup) { foreach ($backups as $backup) {
try { try {
$server = $backup->server(); $server = $backup->server();
$skipReason = $this->getBackupSkipReason($backup, $server);
if ($skipReason !== null) { if (blank(data_get($backup, 'database')) || blank($server)) {
$this->skippedCount++; $this->processScheduledBackup($backup, $server);
$this->logSkip('backup', $skipReason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
continue; continue;
} }
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
$dueBackups[] = [
if (validate_timezone($serverTimezone) === false) { 'backup' => $backup,
$serverTimezone = config('app.timezone'); 'server' => $server,
} ];
$frequency = $backup->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [ Log::channel('scheduled-errors')->error('Error prechecking backup', [
'backup_id' => $backup->id, 'backup_id' => $backup->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return $dueBackups;
} }
private function processScheduledTasks(): void /**
* @param iterable<ScheduledTask> $tasks
* @return array<int, array{task: ScheduledTask, server: Server}>
*/
private function dueScheduledTasks(iterable $tasks): array
{ {
$tasks = ScheduledTask::with(['service', 'application']) $dueTasks = [];
->where('enabled', true)
->get();
foreach ($tasks as $task) { foreach ($tasks as $task) {
try { try {
$server = $task->server(); $server = $task->server();
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues) if (blank($server) || (! $task->service && ! $task->application)) {
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server); $this->processScheduledTask($task, $server);
if ($criticalSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $criticalSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
]);
continue; continue;
} }
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) {
$dueTasks[] = [
if (validate_timezone($serverTimezone) === false) { 'task' => $task,
$serverTimezone = config('app.timezone'); 'server' => $server,
];
} }
$frequency = $task->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
continue;
}
// Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
if ($runtimeSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $runtimeSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
]);
continue;
}
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [ Log::channel('scheduled-errors')->error('Error prechecking task', [
'task_id' => $task->id, 'task_id' => $task->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return $dueTasks;
}
private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void
{
try {
$server = $precheckedServer ?? $backup->server();
$skipReason = $this->getBackupSkipReason($backup, $server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logBackupSkip($backup, $skipReason);
return;
}
if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void
{
try {
$server = $precheckedServer ?? $task->server();
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
if ($criticalSkip !== null) {
$this->skippedCount++;
$this->logTaskSkip($task, $criticalSkip, $server);
return;
}
if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) {
return;
}
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
if ($runtimeSkip !== null) {
$this->skippedCount++;
$this->logTaskSkip($task, $runtimeSkip, $server);
return;
}
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
'task_id' => $task->id,
'error' => $e->getMessage(),
]);
}
} }
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
@ -337,71 +406,70 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
private function processDockerCleanups(): void private function processDockerCleanups(): void
{ {
// Get all servers that need cleanup checks $this->getServersForCleanupQuery()
$servers = $this->getServersForCleanup(); ->chunkById(self::CHUNK_SIZE, function ($servers): void {
foreach ($servers as $server) {
foreach ($servers as $server) { $this->processDockerCleanup($server);
try {
$skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
continue;
} }
});
}
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); private function processDockerCleanup(Server $server): void
if (validate_timezone($serverTimezone) === false) { {
$serverTimezone = config('app.timezone'); try {
} $skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); $this->skippedCount++;
if (isset(VALID_CRON_STRINGS[$frequency])) { $this->logSkip('docker_cleanup', $skipReason, [
$frequency = VALID_CRON_STRINGS[$frequency];
}
// Use the frozen execution time for consistent evaluation
if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
DockerCleanupJob::dispatch(
$server,
false,
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
'server_id' => $server->id, 'server_id' => $server->id,
'server_name' => $server->name, 'server_name' => $server->name,
'error' => $e->getMessage(), 'team_id' => $server->team_id,
]);
return;
}
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) {
DockerCleanupJob::dispatch(
$server,
false,
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]); ]);
} }
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
} }
} }
private function getServersForCleanup(): Collection private function getServersForCleanupQuery(): Builder
{ {
$query = Server::with('settings') $query = Server::with('settings')
->where('ip', '!=', '1.2.3.4'); ->where('ip', '!=', '1.2.3.4');
if (isCloud()) { if (isCloud()) {
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); $query
$own = Team::find(0)->servers()->with('settings')->get(); ->with('team.subscription')
->where(function (Builder $query): void {
return $servers->merge($own); $query
->where('team_id', 0)
->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
});
} }
return $query->get(); return $query;
} }
private function getDockerCleanupSkipReason(Server $server): ?string private function getDockerCleanupSkipReason(Server $server): ?string
@ -428,4 +496,71 @@ private function logSkip(string $type, string $reason, array $context = []): voi
'execution_time' => $this->executionTime?->toIso8601String(), 'execution_time' => $this->executionTime?->toIso8601String(),
], $context)); ], $context));
} }
private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool
{
return shouldRunCronNow(
$this->normalizeFrequency($frequency),
$this->serverTimezone($server),
$dedupKey,
$this->executionTime,
);
}
private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool
{
$cron = new CronExpression($this->normalizeFrequency($frequency));
$executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server));
$lastDispatched = Cache::get($dedupKey);
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
if ($lastDispatched === null) {
$isDue = $cron->isDue($executionTime);
if (! $isDue) {
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
}
return $isDue;
}
$shouldFire = $previousDue->gt(Carbon::parse($lastDispatched));
if (! $shouldFire) {
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
}
return $shouldFire;
}
private function normalizeFrequency(string $frequency): string
{
return VALID_CRON_STRINGS[$frequency] ?? $frequency;
}
private function serverTimezone(Server $server): string
{
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
return validate_timezone($timezone) ? $timezone : config('app.timezone');
}
private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void
{
$this->logSkip('backup', $reason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
}
private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void
{
$this->logSkip('task', $reason, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
]);
}
} }

View file

@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
*/ */
public $timeout = 300; public $timeout = 300;
public Team $team; public ?Team $team = null;
public ?Server $server = null; public ?Server $server = null;
public ScheduledTask $task; public ScheduledTask $task;
public Application|Service $resource; public Application|Service|null $resource = null;
public ?ScheduledTaskExecution $task_log = null; public ?ScheduledTaskExecution $task_log = null;
@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
public array $containers = []; public array $containers = [];
public string $server_timezone; public string $server_timezone = 'UTC';
public function __construct($task) public function __construct(ScheduledTask $task)
{ {
$this->onQueue('high'); $this->onQueue(crons_queue());
$this->task = $task; $this->task = $task;
if ($service = $task->service()->first()) { $this->timeout = $this->task->timeout ?? 300;
$this->resource = $service; }
} elseif ($application = $task->application()->first()) {
$this->resource = $application; private function initializeExecutionContext(): void
{
$this->task->loadMissing([
'service.destination.server.settings',
'application.destination.server.settings',
]);
if ($this->task->service) {
$this->resource = $this->task->service;
} elseif ($this->task->application) {
$this->resource = $this->task->application;
} else { } else {
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.'); throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
} }
$this->team = Team::findOrFail($task->team_id);
$this->server_timezone = $this->getServerTimezone();
// Set timeout from task configuration $this->team = Team::findOrFail($this->task->team_id);
$this->timeout = $this->task->timeout ?? 300; $this->server_timezone = $this->getServerTimezone();
$this->server = $this->resource->destination->server;
} }
private function getServerTimezone(): string private function getServerTimezone(): string
@ -98,6 +107,8 @@ public function handle(): void
$startTime = Carbon::now(); $startTime = Carbon::now();
try { try {
$this->initializeExecutionContext();
$this->task_log = ScheduledTaskExecution::create([ $this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id, 'scheduled_task_id' => $this->task->id,
'started_at' => $startTime, 'started_at' => $startTime,
@ -107,8 +118,6 @@ public function handle(): void
// Store execution ID for timeout handling // Store execution ID for timeout handling
$this->executionId = $this->task_log->id; $this->executionId = $this->task_log->id;
$this->server = $this->resource->destination->server;
if ($this->resource->type() === 'application') { if ($this->resource->type() === 'application') {
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0); $containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
if ($containers->count() > 0) { if ($containers->count() > 0) {
@ -179,7 +188,10 @@ public function handle(): void
// Re-throw to trigger Laravel's retry mechanism with backoff // Re-throw to trigger Laravel's retry mechanism with backoff
throw $e; throw $e;
} finally { } finally {
ScheduledTaskDone::dispatch($this->team->id); if ($this->team) {
ScheduledTaskDone::dispatch($this->team->id);
}
if ($this->task_log) { if ($this->task_log) {
$finishedAt = Carbon::now(); $finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2); $duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
@ -205,6 +217,8 @@ public function backoff(): array
*/ */
public function failed(?\Throwable $exception): void public function failed(?\Throwable $exception): void
{ {
$this->team ??= Team::find($this->task->team_id);
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [ Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob', 'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid, 'task_id' => $this->task->uuid,

View file

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -44,7 +45,7 @@ public function handle(): void
{ {
$validator = Validator::make( $validator = Validator::make(
['webhook_url' => $this->webhookUrl], ['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]] ['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
); );
if ($validator->fails()) { if ($validator->fails()) {

View file

@ -8,6 +8,7 @@
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Services\ConfigurationRepository; use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
@ -212,6 +213,23 @@ private function updateServerDetails()
} }
} }
protected function rules(): array
{
return [
'remoteServerName' => 'required|string',
'remoteServerHost' => 'required|string',
'remoteServerPort' => 'required|integer|min:1|max:65535',
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
];
}
protected function messages(): array
{
return [
...ValidationPatterns::serverUsernameMessages('remoteServerUser', 'SSH User'),
];
}
public function getProxyType() public function getProxyType()
{ {
$this->selectProxy(ProxyTypes::TRAEFIK->value); $this->selectProxy(ProxyTypes::TRAEFIK->value);
@ -274,12 +292,7 @@ public function savePrivateKey()
public function saveServer() public function saveServer()
{ {
$this->validate([ $this->validate();
'remoteServerName' => 'required|string',
'remoteServerHost' => 'required|string',
'remoteServerPort' => 'required|integer',
'remoteServerUser' => 'required|string',
]);
$this->privateKey = formatPrivateKey($this->privateKey); $this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first(); $foundServer = Server::whereIp($this->remoteServerHost)->first();
@ -465,10 +478,10 @@ public function showNewResource()
public function saveAndValidateServer() public function saveAndValidateServer()
{ {
$this->validate([ $this->validate(array_intersect_key($this->rules(), array_flip([
'remoteServerPort' => 'required|integer|min:1|max:65535', 'remoteServerPort',
'remoteServerUser' => 'required|string', 'remoteServerUser',
]); ])));
$this->createdServer->update([ $this->createdServer->update([
'port' => $this->remoteServerPort, 'port' => $this->remoteServerPort,

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Destination; namespace App\Livewire\Destination;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -11,9 +12,15 @@ class Index extends Component
#[Locked] #[Locked]
public $servers; public $servers;
public function mount() #[Locked]
public Collection $destinations;
public function mount(): void
{ {
$this->servers = Server::isUsable()->get(); $this->servers = Server::isUsable()->get();
$this->destinations = $this->servers
->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers))
->values();
} }
public function render() public function render()

View file

@ -33,44 +33,49 @@ class Docker extends Component
#[Validate(['required', 'boolean'])] #[Validate(['required', 'boolean'])]
public bool $isSwarm = false; public bool $isSwarm = false;
public function mount(?string $server_id = null) public function mount(?string $server_id = null): void
{ {
$this->network = new Cuid2; $this->network = (string) new Cuid2;
$this->servers = Server::isUsable()->get(); $this->servers = Server::isUsable()->get();
if ($server_id) {
$foundServer = $this->servers->find($server_id) ?: $this->servers->first(); if (filled($server_id)) {
if (! $foundServer) { $this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail();
throw new \Exception('Server not found.');
if (! $this->servers->contains('id', $this->selectedServer->id)) {
$this->servers->push($this->selectedServer);
} }
$this->selectedServer = $foundServer;
$this->serverId = $this->selectedServer->id; $this->serverId = (string) $this->selectedServer->id;
} else { } else {
$foundServer = $this->servers->first(); $foundServer = $this->servers->first();
if (! $foundServer) { if (! $foundServer) {
throw new \Exception('Server not found.'); throw new \Exception('Server not found.');
} }
$this->selectedServer = $foundServer; $this->selectedServer = $foundServer;
$this->serverId = $this->selectedServer->id; $this->serverId = (string) $this->selectedServer->id;
} }
$this->generateName(); $this->generateName();
} }
public function updatedServerId() public function updatedServerId(): void
{ {
$this->selectedServer = $this->servers->find($this->serverId); $this->selectedServer = $this->servers->find($this->serverId);
if (! $this->selectedServer) {
throw new \Exception('Server not found.');
}
$this->generateName(); $this->generateName();
} }
public function generateName() public function generateName(): void
{ {
$name = data_get($this->selectedServer, 'name', new Cuid2); $name = data_get($this->selectedServer, 'name', new Cuid2);
$this->name = str("{$name}-{$this->network}")->kebab(); $this->name = str("{$name}-{$this->network}")->kebab();
} }
public function submit() public function submit(): mixed
{ {
try { try {
$this->authorize('create', StandaloneDocker::class); $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class);
$this->validate(); $this->validate();
if ($this->isSwarm) { if ($this->isSwarm) {
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();

View file

@ -0,0 +1,125 @@
<?php
namespace App\Livewire\Destination;
use App\Models\Application;
use App\Models\BaseModel;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Resources extends Component
{
#[Locked]
public $destination;
public array $resources = [];
public function mount(string $destination_uuid)
{
try {
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
return redirect()->route('destination.index');
}
if (! $destination instanceof StandaloneDocker) {
return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]);
}
$this->destination = $destination;
$this->loadResources();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Load applications, services, and database resources deployed to the standalone Docker destination.
*
* @return void Populates the resources property for display.
*/
public function loadResources(): void
{
$this->resources = $this->collectResources([
$this->destination->applications,
$this->destination->services,
$this->destination->postgresqls,
$this->destination->redis,
$this->destination->mongodbs,
$this->destination->mysqls,
$this->destination->mariadbs,
$this->destination->keydbs,
$this->destination->dragonflies,
$this->destination->clickhouses,
]);
}
/**
* @param array<int, iterable<Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse>> $groups
* @return array<int, array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}>
*/
protected function collectResources(array $groups): array
{
$rows = [];
foreach ($groups as $group) {
foreach ($group as $resource) {
$rows[] = $this->resourceRow($resource);
}
}
return $rows;
}
/**
* @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource
* @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}
*/
protected function resourceRow(BaseModel $resource): array
{
$type = match (true) {
$resource instanceof Application => 'application',
$resource instanceof Service => 'service',
default => 'database',
};
$environment = $resource->environment;
$project = $environment?->project;
$routeName = "project.{$type}.configuration";
$url = ($project && $environment)
? route($routeName, [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
"{$type}_uuid" => $resource->uuid,
])
: null;
return [
'uuid' => $resource->uuid,
'type' => $type,
'name' => $resource->name,
'project' => $project?->name,
'environment' => $environment?->name,
'url' => $url,
'search' => strtolower(implode(' ', array_filter([
$type,
$resource->name,
$project?->name,
$environment?->name,
]))),
];
}
public function render(): View
{
return view('livewire.destination.resources');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Profile;
use Livewire\Component;
class Appearance extends Component
{
public function render()
{
return view('livewire.profile.appearance');
}
}

View file

@ -87,6 +87,9 @@ class Advanced extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $isConnectToDockerNetworkEnabled = false; public bool $isConnectToDockerNetworkEnabled = false;
#[Validate(['integer', 'min:0'])]
public int $maxRestartCount = 10;
public function mount() public function mount()
{ {
try { try {
@ -149,6 +152,7 @@ public function syncData(bool $toModel = false)
$this->disableBuildCache = $this->application->settings->disable_build_cache; $this->disableBuildCache = $this->application->settings->disable_build_cache;
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
$this->maxRestartCount = $this->application->max_restart_count ?? 10;
} }
// Load stop_grace_period separately since it has its own save handler // Load stop_grace_period separately since it has its own save handler
@ -289,6 +293,21 @@ public function saveStopGracePeriod()
} }
} }
public function saveMaxRestartCount()
{
try {
$this->authorize('update', $this->application);
$this->validate([
'maxRestartCount' => 'integer|min:0',
]);
$this->application->max_restart_count = $this->maxRestartCount;
$this->application->save();
$this->dispatch('success', 'Max restart count saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render() public function render()
{ {
return view('livewire.project.application.advanced'); return view('livewire.project.application.advanced');

View file

@ -17,17 +17,10 @@ class Configuration extends Component
public $servers; public $servers;
public function getListeners() protected $listeners = [
{ 'buildPackUpdated' => '$refresh',
$teamId = auth()->user()->currentTeam()->id; 'refresh' => '$refresh',
];
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
"echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
'buildPackUpdated' => '$refresh',
'refresh' => '$refresh',
];
}
public function mount() public function mount()
{ {
@ -35,7 +28,7 @@ public function mount()
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
->select('id', 'uuid', 'team_id') ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid')) ->where('uuid', request()->route('project_uuid'))
->firstOrFail(); ->firstOrFail();
$environment = $project->environments() $environment = $project->environments()
@ -51,8 +44,6 @@ public function mount()
$this->environment = $environment; $this->environment = $environment;
$this->application = $application; $this->application = $application;
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
} }

View file

@ -5,6 +5,7 @@
use App\Actions\Application\GenerateConfig; use App\Actions\Application\GenerateConfig;
use App\Jobs\ApplicationDeploymentJob; use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application; use App\Models\Application;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -144,7 +145,7 @@ protected function rules(): array
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable', 'fqdn' => 'nullable',
'gitRepository' => 'required', 'gitRepository' => 'required',
'gitBranch' => 'required', 'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => ValidationPatterns::shellSafeCommandRules(), 'installCommand' => ValidationPatterns::shellSafeCommandRules(),
'buildCommand' => ValidationPatterns::shellSafeCommandRules(), 'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
@ -153,12 +154,12 @@ protected function rules(): array
'staticImage' => 'required', 'staticImage' => 'required',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(), 'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'], 'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
'portsMappings' => ValidationPatterns::portMappingRules(), 'portsMappings' => ValidationPatterns::portMappingRules(),
'customNetworkAliases' => 'nullable', 'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable', 'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
'dockerRegistryImageTag' => 'nullable', 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
'dockerfileLocation' => ValidationPatterns::filePathRules(), 'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable', 'dockerCompose' => 'nullable',
@ -211,7 +212,6 @@ protected function messages(): array
'buildPack.required' => 'The Build Pack field is required.', 'buildPack.required' => 'The Build Pack field is required.',
'staticImage.required' => 'The Static Image field is required.', 'staticImage.required' => 'The Static Image field is required.',
'baseDirectory.required' => 'The Base Directory field is required.', 'baseDirectory.required' => 'The Base Directory field is required.',
'portsExposes.required' => 'The Exposed Ports field is required.',
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).', 'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
...ValidationPatterns::portMappingMessages(), ...ValidationPatterns::portMappingMessages(),
'isStatic.required' => 'The Static setting is required.', 'isStatic.required' => 'The Static setting is required.',
@ -759,7 +759,7 @@ public function submit($showToaster = true)
$this->resetErrorBag(); $this->resetErrorBag();
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString(); $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
if ($this->portsMappings) { if ($this->portsMappings) {
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
} }
@ -848,7 +848,7 @@ public function submit($showToaster = true)
} }
if ($this->buildPack === 'dockerimage') { if ($this->buildPack === 'dockerimage') {
$this->validate([ $this->validate([
'dockerRegistryImageName' => 'required', 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
]); ]);
} }

View file

@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ServerStatusBadge extends Component
{
public Application $application;
public function getListeners(): array
{
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus',
];
}
public function refreshStatus(): void
{
$this->application->refresh();
}
public function render(): View
{
return view('livewire.project.application.server-status-badge');
}
}

View file

@ -3,7 +3,10 @@
namespace App\Livewire\Project\Application; namespace App\Livewire\Project\Application;
use App\Models\Application; use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Rules\ValidGitBranch;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@ -21,13 +24,13 @@ class Source extends Component
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $privateKeyName = null; public ?string $privateKeyName = null;
#[Validate(['nullable', 'integer'])] #[Locked]
public ?int $privateKeyId = null; public ?int $privateKeyId = null;
#[Validate(['required', 'string'])] #[Validate(['required', 'string'])]
public string $gitRepository; public string $gitRepository;
#[Validate(['required', 'string'])] #[Validate(['required', 'string', new ValidGitBranch])]
public string $gitBranch; public string $gitBranch;
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] #[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
@ -103,7 +106,8 @@ public function setPrivateKey(int $privateKeyId)
{ {
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->privateKeyId = $privateKeyId; $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
$this->privateKeyId = $key->id;
$this->syncData(true); $this->syncData(true);
$this->getPrivateKeys(); $this->getPrivateKeys();
$this->application->refresh(); $this->application->refresh();
@ -136,8 +140,11 @@ public function changeSource($sourceId, $sourceType)
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
$source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
$this->application->update([ $this->application->update([
'source_id' => $sourceId, 'source_id' => $source->id,
'source_type' => $sourceType, 'source_type' => $sourceType,
]); ]);

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -144,7 +145,7 @@ public function delete($password, $selectedActions = [])
try { try {
$server = null; $server = null;
if ($this->backup->database instanceof \App\Models\ServiceDatabase) { if ($this->backup->database instanceof ServiceDatabase) {
$server = $this->backup->database->service->destination->server; $server = $this->backup->database->service->destination->server;
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) { } elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
$server = $this->backup->database->destination->server; $server = $this->backup->database->destination->server;
@ -170,7 +171,7 @@ public function delete($password, $selectedActions = [])
$this->backup->delete(); $this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database; $serviceDatabase = $this->backup->database;
return redirect()->route('project.service.database.backups', [ return redirect()->route('project.service.database.backups', [
@ -182,7 +183,7 @@ public function delete($password, $selectedActions = [])
} else { } else {
return redirect()->route('project.database.backup.index', $this->parameters); return redirect()->route('project.database.backup.index', $this->parameters);
} }
} catch (\Exception $e) { } catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return handleError($e, $this); return handleError($e, $this);
@ -207,6 +208,13 @@ private function customValidate()
$this->backup->s3_storage_id = null; $this->backup->s3_storage_id = null;
} }
// S3 backup cannot be enabled without a valid S3 storage owned by the team
$availableS3Ids = collect($this->s3s)->pluck('id');
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->save_s3 = $this->saveS3 = false;
$this->backup->s3_storage_id = $this->s3StorageId = null;
}
// Validate that disable_local_backup can only be true when S3 backup is enabled // Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
$this->backup->disable_local_backup = $this->disableLocalBackup = false; $this->backup->disable_local_backup = $this->disableLocalBackup = false;
@ -214,7 +222,7 @@ private function customValidate()
$isValid = validate_cron_expression($this->backup->frequency); $isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) { if (! $isValid) {
throw new \Exception('Invalid Cron / Human expression'); throw new Exception('Invalid Cron / Human expression');
} }
$this->validate(); $this->validate();
} }

View file

@ -40,18 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public function getListeners() public function getListeners(): array
{ {
$teamId = Auth::user()->currentTeam()->id; $user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
]; ];
} }
@ -88,8 +91,6 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
]; ];
} }
@ -129,9 +130,6 @@ public function syncData(bool $toModel = false)
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -144,8 +142,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -194,6 +190,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -202,9 +199,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -220,6 +221,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {

View file

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Project\Database\Clickhouse;
use App\Models\StandaloneClickhouse;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneClickhouse $database;
protected function databaseLabel(): string
{
return 'Clickhouse';
}
protected function supportsSsl(): bool
{
return false;
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -2,8 +2,9 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use Auth; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\ItemNotFoundException;
use Livewire\Component; use Livewire\Component;
class Configuration extends Component class Configuration extends Component
@ -18,15 +19,6 @@ class Configuration extends Component
public $environment; public $environment;
public function getListeners()
{
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function mount() public function mount()
{ {
try { try {
@ -34,7 +26,7 @@ public function mount()
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
->select('id', 'uuid', 'team_id') ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid')) ->where('uuid', request()->route('project_uuid'))
->firstOrFail(); ->firstOrFail();
$environment = $project->environments() $environment = $project->environments()
@ -55,10 +47,10 @@ public function mount()
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { if ($e instanceof AuthorizationException) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
if ($e instanceof \Illuminate\Support\ItemNotFoundException) { if ($e instanceof ItemNotFoundException) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }

View file

@ -2,7 +2,9 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -48,6 +50,20 @@ public function submit()
$this->validate(); $this->validate();
if ($this->saveToS3) {
$s3StorageExists = ! is_null($this->s3StorageId)
&& S3Storage::where('team_id', currentTeam()->id)
->where('is_usable', true)
->whereKey($this->s3StorageId)
->exists();
if (! $s3StorageExists) {
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
return;
}
}
$isValid = validate_cron_expression($this->frequency); $isValid = validate_cron_expression($this->frequency);
if (! $isValid) { if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.'); $this->dispatch('error', 'Invalid Cron / Human expression.');
@ -74,7 +90,7 @@ public function submit()
} }
$databaseBackup = ScheduledDatabaseBackup::create($payload); $databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id); $this->dispatch('refreshScheduledBackups', $databaseBackup->id);
} else { } else {
$this->dispatch('refreshScheduledBackups'); $this->dispatch('refreshScheduledBackups');

View file

@ -4,11 +4,9 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -40,25 +38,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null; public function getListeners(): array
public bool $enable_ssl = false;
public function getListeners()
{ {
$userId = Auth::id(); $user = Auth::user();
$teamId = Auth::user()->currentTeam()->id; if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
]; ];
} }
@ -73,12 +67,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -98,10 +86,7 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'nullable|boolean',
]; ];
} }
@ -137,11 +122,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -153,9 +134,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -204,6 +182,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -212,9 +191,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -230,6 +213,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -241,67 +225,6 @@ public function submit()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Project\Database\Dragonfly;
use App\Models\StandaloneDragonfly;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneDragonfly $database;
protected function databaseLabel(): string
{
return 'Dragonfly';
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Livewire\Project\Database;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Health extends Component
{
use AuthorizesRequests;
public $database;
#[Validate(['boolean'])]
public bool $healthCheckEnabled = true;
#[Validate(['integer', 'min:1'])]
public int $healthCheckInterval = 15;
#[Validate(['integer', 'min:1'])]
public int $healthCheckTimeout = 5;
#[Validate(['integer', 'min:1'])]
public int $healthCheckRetries = 5;
#[Validate(['integer', 'min:0'])]
public int $healthCheckStartPeriod = 5;
public function mount(): void
{
$this->authorize('view', $this->database);
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->database->health_check_enabled = $this->healthCheckEnabled;
$this->database->health_check_interval = $this->healthCheckInterval;
$this->database->health_check_timeout = $this->healthCheckTimeout;
$this->database->health_check_retries = $this->healthCheckRetries;
$this->database->health_check_start_period = $this->healthCheckStartPeriod;
$this->database->save();
} else {
$this->healthCheckEnabled = $this->database->health_check_enabled;
$this->healthCheckInterval = $this->database->health_check_interval;
$this->healthCheckTimeout = $this->database->health_check_timeout;
$this->healthCheckRetries = $this->database->health_check_retries;
$this->healthCheckStartPeriod = $this->database->health_check_start_period;
}
}
public function instantSave(): void
{
$this->submit();
}
public function submit(): void
{
$updateSuccessful = false;
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}
if (! $updateSuccessful) {
return;
}
$this->markConfigurationChanged();
}
public function toggleHealthcheck(): void
{
$updateSuccessful = false;
try {
$this->authorize('update', $this->database);
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}
if (! $updateSuccessful) {
return;
}
$this->markConfigurationChanged();
}
private function markConfigurationChanged(): void
{
if (is_null($this->database->config_hash)) {
$this->database->isConfigurationChanged(true);
return;
}
$this->dispatch('configurationChanged');
}
public function render(): View
{
return view('livewire.project.database.health');
}
}

View file

@ -2,14 +2,14 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\S3Storage; use App\Models\ServiceDatabase;
use App\Models\Server; use App\Models\StandaloneClickhouse;
use App\Models\Service; use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns; use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -17,797 +17,134 @@ class Import extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
#[Locked] #[Locked]
public ?int $resourceId = null; public ?int $resourceId = null;
#[Locked] #[Locked]
public ?string $resourceType = null; public ?string $resourceType = null;
#[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
#[Locked]
public string $resourceUuid = '';
public string $resourceStatus = ''; public string $resourceStatus = '';
#[Locked] public string $resourceUuid = '';
public string $resourceDbType = '';
public array $parameters = []; public bool $unsupported = false;
public array $containers = []; public function getListeners(): array
public bool $scpInProgress = false;
public bool $importRunning = false;
public ?string $filename = null;
public ?string $filesize = null;
public bool $isUploading = false;
public int $progress = 0;
public bool $error = false;
#[Locked]
public string $container;
public array $importCommands = [];
public bool $dumpAll = false;
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{ {
if ($this->resourceId === null || $this->resourceType === null) { $listeners = ['databaseUpdated' => 'refreshStatus'];
return null;
$user = Auth::user();
if (! $user) {
return $listeners;
} }
return $this->resourceType::find($this->resourceId); $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
}
#[Computed] $team = $user->currentTeam();
public function server() if ($team) {
{ $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
if ($this->serverId === null) {
return null;
} }
return Server::ownedByCurrentTeam()->find($this->serverId); return $listeners;
} }
public function getListeners() public function mount(): void
{ {
$userId = Auth::id(); $resource = $this->resolveResourceFromRoute();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'slideOverClosed' => 'resetActivityId',
];
}
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
break;
}
}
public function getContainers()
{
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$service = Service::whereUuid($serviceUuid)->first();
if (! $service) {
abort(404);
}
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource); $this->authorize('view', $resource);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id; $this->resourceId = $resource->id;
$this->resourceType = get_class($resource); $this->resourceType = get_class($resource);
// Store view-friendly properties $this->refreshStatus();
}
public function refreshStatus(): void
{
$resource = $this->resolveStoredResource();
$this->authorize('view', $resource);
$resource->refresh();
$this->resourceUuid = $resource->uuid;
$this->resourceStatus = $resource->status ?? ''; $this->resourceStatus = $resource->status ?? '';
$this->unsupported = $this->isUnsupportedResource($resource);
}
// Handle ServiceDatabase server access differently public function render(): View
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { {
$server = $resource->service?->server; return view('livewire.project.database.import');
if (! $server) { }
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase private function resolveResourceFromRoute(): object
$dbType = $resource->databaseType(); {
if (str_contains($dbType, 'postgres')) { $parameters = get_route_parameters();
$this->resourceDbType = 'standalone-postgresql'; $teamId = data_get(Auth::user()?->currentTeam(), 'id');
} elseif (str_contains($dbType, 'mysql')) { $databaseUuid = data_get($parameters, 'database_uuid');
$this->resourceDbType = 'standalone-mysql'; $stackServiceUuid = data_get($parameters, 'stack_service_uuid');
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb'; if ($databaseUuid) {
} elseif (str_contains($dbType, 'mongo')) { $resource = getResourceByUuid($databaseUuid, $teamId);
$this->resourceDbType = 'standalone-mongodb'; if ($resource) {
} else { return $resource;
$this->resourceDbType = $dbType;
} }
} else {
$server = $resource->destination?->server; abort(404);
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
} }
if (str($resource->status)->startsWith('running')) { if ($stackServiceUuid) {
$this->containers[] = $this->container; $project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if ($resource) {
return $resource;
}
} }
abort(404);
}
private function resolveStoredResource(): object
{
if ($this->resourceId === null || $this->resourceType === null) {
return $this->resolveResourceFromRoute();
}
$resource = $this->resourceType::find($this->resourceId);
if ($resource) {
return $resource;
}
abort(404);
}
private function isUnsupportedResource(object $resource): bool
{
if ( if (
$resource->getMorphClass() === \App\Models\StandaloneRedis::class || $resource instanceof StandaloneRedis ||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class || $resource instanceof StandaloneKeydb ||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || $resource instanceof StandaloneDragonfly ||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class $resource instanceof StandaloneClickhouse
) { ) {
$this->unsupported = true; return true;
} }
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) if ($resource instanceof ServiceDatabase) {
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$dbType = $resource->databaseType(); $dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile() return str_contains($dbType, 'redis') ||
{ str_contains($dbType, 'keydb') ||
if (filled($this->customLocation)) { str_contains($dbType, 'dragonfly') ||
// Validate the custom location to prevent command injection str_contains($dbType, 'clickhouse');
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
$this->filename = $this->customLocation;
$this->dispatch('success', 'The file exists.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
public function runImport(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
} }
$this->authorize('update', $this->resource); return false;
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
handleError($e, $this);
return true;
} finally {
$this->filename = null;
$this->importCommands = [];
}
return true;
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = [];
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return true;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedBucket = escapeshellarg($bucket);
$escapedCleanPath = escapeshellarg($cleanPath);
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$commands[] = "chmod +x {$scriptPath}";
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return true;
}
return true;
}
public function buildRestoreCommand(string $tmpPath): string
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
} }
} }

View file

@ -0,0 +1,825 @@
<?php
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ImportForm extends Component
{
use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
#[Locked]
public ?int $resourceId = null;
#[Locked]
public ?string $resourceType = null;
#[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
#[Locked]
public string $resourceUuid = '';
public string $resourceStatus = '';
#[Locked]
public string $resourceDbType = '';
public array $parameters = [];
public array $containers = [];
public bool $scpInProgress = false;
public bool $importRunning = false;
public ?string $filename = null;
public ?string $filesize = null;
public bool $isUploading = false;
public int $progress = 0;
public bool $error = false;
#[Locked]
public string $container;
public array $importCommands = [];
public bool $dumpAll = false;
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{
if ($this->resourceId === null || $this->resourceType === null) {
return null;
}
return $this->resourceType::find($this->resourceId);
}
#[Computed]
public function server()
{
if ($this->serverId === null) {
return null;
}
return Server::ownedByCurrentTeam()->find($this->serverId);
}
protected $listeners = [
'slideOverClosed' => 'resetActivityId',
];
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
break;
}
}
public function getContainers()
{
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($this->parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($this->parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
// Store view-friendly properties
$this->resourceStatus = $resource->status ?? '';
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === ServiceDatabase::class) {
$server = $resource->service?->server;
if (! $server) {
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase
$dbType = $resource->databaseType();
if (str_contains($dbType, 'postgres')) {
$this->resourceDbType = 'standalone-postgresql';
} elseif (str_contains($dbType, 'mysql')) {
$this->resourceDbType = 'standalone-mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb';
} elseif (str_contains($dbType, 'mongo')) {
$this->resourceDbType = 'standalone-mongodb';
} else {
$this->resourceDbType = $dbType;
}
} else {
$server = $resource->destination?->server;
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
}
if (str($resource->status)->startsWith('running')) {
$this->containers[] = $this->container;
}
if (
$resource->getMorphClass() === StandaloneRedis::class ||
$resource->getMorphClass() === StandaloneKeydb::class ||
$resource->getMorphClass() === StandaloneDragonfly::class ||
$resource->getMorphClass() === StandaloneClickhouse::class
) {
$this->unsupported = true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === ServiceDatabase::class) {
$dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile()
{
if (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
$this->filename = $this->customLocation;
$this->dispatch('success', 'The file exists.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
public function runImport(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
handleError($e, $this);
return true;
} finally {
$this->filename = null;
$this->importCommands = [];
}
return true;
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = [];
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return true;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
$escapedScriptPath = escapeshellarg($scriptPath);
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
$commands[] = "chmod +x {$escapedScriptPath}";
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return true;
}
return true;
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$escapedTmpPath}";
}
break;
case StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
}
}

View file

@ -4,11 +4,9 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -42,25 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null; public function getListeners(): array
public bool $enable_ssl = false;
public function getListeners()
{ {
$userId = Auth::id(); $user = Auth::user();
$teamId = Auth::user()->currentTeam()->id; if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
]; ];
} }
@ -75,12 +69,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -88,7 +76,7 @@ public function mount()
protected function rules(): array protected function rules(): array
{ {
$baseRules = [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string', 'keydbConf' => 'nullable|string',
@ -101,13 +89,8 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'boolean',
]; ];
return $baseRules;
} }
protected function messages(): array protected function messages(): array
@ -143,11 +126,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -160,9 +139,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -211,6 +187,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -219,9 +196,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -237,6 +218,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -248,65 +230,6 @@ public function submit()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Project\Database\Keydb;
use App\Models\StandaloneKeydb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneKeydb $database;
protected function databaseLabel(): string
{
return 'KeyDB';
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneMariadb; use App\Models\StandaloneMariadb;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -50,25 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -94,7 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
]; ];
} }
@ -133,7 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options', 'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL',
]; ];
public function mount() public function mount()
@ -147,12 +123,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -176,11 +146,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -196,9 +162,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -234,6 +197,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -270,6 +234,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -278,63 +243,6 @@ public function instantSave()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Project\Database\Mariadb;
use App\Models\StandaloneMariadb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMariadb $database;
protected function databaseLabel(): string
{
return 'MariaDB';
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneMongodb; use App\Models\StandaloneMongodb;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -48,27 +45,6 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -91,8 +67,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
]; ];
} }
@ -112,7 +86,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
] ]
); );
} }
@ -130,8 +103,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@ -145,12 +116,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -173,12 +138,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -193,10 +153,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -235,6 +191,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -271,6 +228,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -279,68 +237,6 @@ public function instantSave()
} }
} }
public function updatedSslMode()
{
$this->instantSaveSSL();
}
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mongodb;
use App\Models\StandaloneMongodb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMongodb $database;
protected function databaseLabel(): string
{
return 'Mongo';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MongoDB connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -50,27 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -96,8 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
]; ];
} }
@ -118,7 +92,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
] ]
); );
} }
@ -137,8 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@ -152,12 +123,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -181,12 +146,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -202,10 +162,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -241,6 +197,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -277,6 +234,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -285,68 +243,6 @@ public function instantSave()
} }
} }
public function updatedSslMode()
{
$this->instantSaveSSL();
}
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mysql;
use App\Models\StandaloneMysql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMysql $database;
protected function databaseLabel(): string
{
return 'MySQL';
}
protected function sslModeOptions(): array
{
return [
'PREFERRED' => ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'],
'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'],
'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'],
'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MySQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -54,32 +51,14 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public string $new_filename; public string $new_filename;
public string $new_content; public string $new_content;
public ?string $db_url = null; protected $listeners = [
'save_init_script',
public ?string $db_url_public = null; 'delete_init_script',
];
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'save_init_script',
'delete_init_script',
];
}
protected function rules(): array protected function rules(): array
{ {
@ -106,8 +85,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
]; ];
} }
@ -127,7 +104,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
] ]
); );
} }
@ -148,8 +124,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@ -163,12 +137,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -194,12 +162,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -217,10 +180,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -243,68 +202,6 @@ public function instantSaveAdvanced()
} }
} }
public function updatedSslMode()
{
$this->instantSaveSSL();
}
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function instantSave() public function instantSave()
{ {
try { try {
@ -330,6 +227,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -493,6 +391,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Project\Database\Postgresql;
use App\Models\StandalonePostgresql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandalonePostgresql $database;
protected function databaseLabel(): string
{
return 'Postgres';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for PostgreSQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -48,25 +45,9 @@ class General extends Component
public string $redisVersion; public string $redisVersion;
public ?string $dbUrl = null; protected $listeners = [
'envsUpdated' => 'refresh',
public ?string $dbUrlPublic = null; ];
public bool $enableSsl = false;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'envsUpdated' => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
@ -87,7 +68,6 @@ protected function rules(): array
'redisPassword' => ValidationPatterns::databasePasswordRules( 'redisPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->redisPassword !== $this->database->redis_password, enforcePattern: $this->redisPassword !== $this->database->redis_password,
), ),
'enableSsl' => 'boolean',
]; ];
} }
@ -122,7 +102,6 @@ protected function messages(): array
'customDockerRunOptions' => 'Custom Docker Options', 'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username', 'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password', 'redisPassword' => 'Redis Password',
'enableSsl' => 'Enable SSL',
]; ];
public function mount() public function mount()
@ -136,12 +115,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -161,11 +134,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -177,9 +146,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
$this->redisVersion = $this->database->getRedisVersion(); $this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username; $this->redisUsername = $this->database->redis_username;
$this->redisPassword = $this->database->redis_password; $this->redisPassword = $this->database->redis_password;
@ -227,6 +193,7 @@ public function submit()
); );
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -259,6 +226,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -267,63 +235,6 @@ public function instantSave()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Project\Database\Redis;
use App\Models\StandaloneRedis;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneRedis $database;
protected function databaseLabel(): string
{
return 'Redis';
}
}

View file

@ -4,12 +4,14 @@
use App\Models\Environment; use App\Models\Environment;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
class DeleteEnvironment extends Component class DeleteEnvironment extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
#[Locked]
public int $environment_id; public int $environment_id;
public bool $disabled = false; public bool $disabled = false;
@ -20,12 +22,8 @@ class DeleteEnvironment extends Component
public function mount() public function mount()
{ {
try { $this->parameters = get_route_parameters();
$this->environmentName = Environment::findOrFail($this->environment_id)->name; $this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name;
$this->parameters = get_route_parameters();
} catch (\Exception $e) {
return handleError($e, $this);
}
} }
public function delete() public function delete()
@ -33,7 +31,7 @@ public function delete()
$this->validate([ $this->validate([
'environment_id' => 'required|int', 'environment_id' => 'required|int',
]); ]);
$environment = Environment::findOrFail($this->environment_id); $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment); $this->authorize('delete', $environment);
if ($environment->isEmpty()) { if ($environment->isEmpty()) {

View file

@ -5,6 +5,7 @@
use App\Models\Application; use App\Models\Application;
use App\Models\Project; use App\Models\Project;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@ -81,8 +82,8 @@ public function updatedImageName(): void
public function submit() public function submit()
{ {
$this->validate([ $this->validate([
'imageName' => ['required', 'string'], 'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], 'imageTag' => ValidationPatterns::dockerImageTagRules(),
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]); ]);

View file

@ -9,6 +9,7 @@
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
class GithubPrivateRepository extends Component class GithubPrivateRepository extends Component
@ -29,6 +30,7 @@ class GithubPrivateRepository extends Component
public int $selected_repository_id; public int $selected_repository_id;
#[Locked]
public int $selected_github_app_id; public int $selected_github_app_id;
public string $selected_repository_owner; public string $selected_repository_owner;
@ -37,8 +39,6 @@ class GithubPrivateRepository extends Component
public string $selected_branch_name = 'main'; public string $selected_branch_name = 'main';
public string $token;
public $repositories; public $repositories;
public int $total_repositories_count = 0; public int $total_repositories_count = 0;
@ -71,7 +71,10 @@ public function mount()
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->query = request()->query(); $this->query = request()->query();
$this->repositories = $this->branches = collect(); $this->repositories = $this->branches = collect();
$this->github_apps = GithubApp::private(); $this->github_apps = GithubApp::ownedByCurrentTeam()
->where('is_public', false)
->whereNotNull('app_id')
->get();
} }
public function updatedSelectedRepositoryId(): void public function updatedSelectedRepositoryId(): void
@ -96,22 +99,25 @@ public function updatedBuildPack()
} }
} }
public function loadRepositories($github_app_id) public function loadRepositories(int $github_app_id): void
{ {
$this->repositories = collect(); $this->repositories = collect();
$this->branches = collect(); $this->branches = collect();
$this->total_branches_count = 0; $this->total_branches_count = 0;
$this->page = 1; $this->page = 1;
$this->selected_github_app_id = $github_app_id; $this->selected_github_app_id = $github_app_id;
$this->github_app = GithubApp::where('id', $github_app_id)->first(); $this->github_app = GithubApp::ownedByCurrentTeam()
$this->token = generateGithubInstallationToken($this->github_app); ->where('is_public', false)
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); ->whereNotNull('app_id')
->findOrFail($github_app_id);
$token = generateGithubInstallationToken($this->github_app);
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count']; $this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories'])); $this->repositories = $this->repositories->concat(collect($repositories['repositories']));
if ($this->repositories->count() < $this->total_repositories_count) { if ($this->repositories->count() < $this->total_repositories_count) {
while ($this->repositories->count() < $this->total_repositories_count) { while ($this->repositories->count() < $this->total_repositories_count) {
$this->page++; $this->page++;
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); $repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count']; $this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories'])); $this->repositories = $this->repositories->concat(collect($repositories['repositories']));
} }
@ -142,7 +148,9 @@ public function loadBranches()
protected function loadBranchByPage() protected function loadBranchByPage()
{ {
$response = Http::GitHub($this->github_app->api_url, $this->token) $token = generateGithubInstallationToken($this->github_app);
$response = Http::GitHub($this->github_app->api_url, $token)
->timeout(20) ->timeout(20)
->retry(3, 200, throw: false) ->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [ ->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [

View file

@ -4,7 +4,9 @@
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Component; use Livewire\Component;
class Select extends Component class Select extends Component
@ -105,7 +107,9 @@ public function updatedSelectedEnvironment()
public function loadServices() public function loadServices()
{ {
$services = get_service_templates(); $services = get_service_templates();
$services = collect($services)->map(function ($service, $key) { $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys());
$services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) {
$default_logo = 'images/default.webp'; $default_logo = 'images/default.webp';
$logo = data_get($service, 'logo', $default_logo); $logo = data_get($service, 'logo', $default_logo);
$local_logo_path = public_path($logo); $local_logo_path = public_path($logo);
@ -116,6 +120,7 @@ public function loadServices()
'logo_github_url' => file_exists($local_logo_path) 'logo_github_url' => file_exists($local_logo_path)
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
: asset($default_logo), : asset($default_logo),
'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null,
] + (array) $service; ] + (array) $service;
})->all(); })->all();
@ -247,6 +252,7 @@ public function loadServices()
]; ];
return [ return [
'serviceTemplatesLastUpdated' => $this->serviceTemplatesLastUpdated(),
'services' => $services, 'services' => $services,
'categories' => $categories, 'categories' => $categories,
'gitBasedApplications' => $gitBasedApplications, 'gitBasedApplications' => $gitBasedApplications,
@ -268,6 +274,55 @@ public function instantSave()
} }
} }
private function serviceTemplatesLastUpdated(): ?string
{
return $this->formatLastModified($this->serviceTemplatesPath());
}
private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array
{
$bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0;
return Cache::remember(
"service-template-last-updated-map:{$bundleMtime}",
now()->addDay(),
fn () => $serviceNames
->mapWithKeys(fn ($serviceName) => [
(string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName),
])
->all()
);
}
private function serviceTemplateLastUpdated(string $serviceName): ?string
{
foreach (['yaml', 'yml'] as $extension) {
$templatePath = base_path("templates/compose/{$serviceName}.{$extension}");
if (file_exists($templatePath)) {
return $this->formatLastModified($templatePath);
}
}
return null;
}
private function serviceTemplatesPath(): string
{
return base_path('templates/'.config('constants.services.file_name'));
}
private function formatLastModified(string $path): ?string
{
if (! file_exists($path)) {
return null;
}
return CarbonImmutable::createFromTimestamp(filemtime($path))
->timezone(config('app.timezone'))
->format('M j, Y H:i');
}
public function setType(string $type) public function setType(string $type)
{ {
$type = str($type)->lower()->slug()->value(); $type = str($type)->lower()->slug()->value();

View file

@ -4,7 +4,6 @@
use App\Models\Service; use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class Configuration extends Component class Configuration extends Component
@ -27,16 +26,10 @@ class Configuration extends Component
public array $parameters; public array $parameters;
public function getListeners() protected $listeners = [
{ 'refreshServices' => 'refreshServices',
$teamId = Auth::user()->currentTeam()->id; 'refresh' => 'refreshServices',
];
return [
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
];
}
public function render() public function render()
{ {
@ -51,7 +44,7 @@ public function mount()
$this->query = request()->query(); $this->query = request()->query();
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
->select('id', 'uuid', 'team_id') ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid')) ->where('uuid', request()->route('project_uuid'))
->firstOrFail(); ->firstOrFail();
$environment = $project->environments() $environment = $project->environments()
@ -105,18 +98,4 @@ public function restartDatabase($id)
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function serviceChecked()
{
try {
$this->service->applications->each(function ($application) {
$application->refresh();
});
$this->service->databases->each(function ($database) {
$database->refresh();
});
} catch (\Exception $e) {
return handleError($e, $this);
}
}
} }

View file

@ -28,10 +28,16 @@ public function mount()
try { try {
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->query = request()->query(); $this->query = request()->query();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); $project = currentTeam()
if (! $this->service) { ->projects()
return redirect()->route('dashboard'); ->select('id', 'uuid', 'team_id')
} ->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service); $this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();

View file

@ -7,12 +7,15 @@
use App\Actions\Service\StopService; use App\Actions\Service\StopService;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
use App\Models\Service; use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
class Heading extends Component class Heading extends Component
{ {
use AuthorizesRequests;
public Service $service; public Service $service;
public array $parameters; public array $parameters;
@ -27,6 +30,8 @@ class Heading extends Component
public function mount() public function mount()
{ {
$this->authorizeService('view');
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) { if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true); $this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
@ -47,6 +52,8 @@ public function getListeners()
public function checkStatus() public function checkStatus()
{ {
$this->authorizeService('view');
if ($this->service->server->isFunctional()) { if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server); GetContainersStatus::dispatch($this->service->server);
} else { } else {
@ -61,6 +68,8 @@ public function manualCheckStatus()
public function serviceChecked() public function serviceChecked()
{ {
$this->authorizeService('view');
try { try {
$this->service->applications->each(function ($application) { $this->service->applications->each(function ($application) {
$application->refresh(); $application->refresh();
@ -82,6 +91,8 @@ public function serviceChecked()
public function checkDeployments() public function checkDeployments()
{ {
$this->authorizeService('view');
try { try {
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first(); $activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
$status = data_get($activity, 'properties.status'); $status = data_get($activity, 'properties.status');
@ -99,12 +110,16 @@ public function checkDeployments()
public function start() public function start()
{ {
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true); $activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} }
public function forceDeploy() public function forceDeploy()
{ {
$this->authorizeService('deploy');
try { try {
$activities = Activity::where('properties->type_uuid', $this->service->uuid) $activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) { ->where(function ($q) {
@ -124,6 +139,8 @@ public function forceDeploy()
public function stop() public function stop()
{ {
$this->authorizeService('stop');
try { try {
StopService::dispatch($this->service, false, $this->docker_cleanup); StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) { } catch (\Exception $e) {
@ -133,6 +150,8 @@ public function stop()
public function restart() public function restart()
{ {
$this->authorizeService('deploy');
$this->checkDeployments(); $this->checkDeployments();
if ($this->isDeploymentProgress) { if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.'); $this->dispatch('error', 'There is a deployment in progress.');
@ -145,6 +164,8 @@ public function restart()
public function pullAndRestartEvent() public function pullAndRestartEvent()
{ {
$this->authorizeService('deploy');
$this->checkDeployments(); $this->checkDeployments();
if ($this->isDeploymentProgress) { if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.'); $this->dispatch('error', 'There is a deployment in progress.');
@ -155,6 +176,15 @@ public function pullAndRestartEvent()
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} }
private function authorizeService(string $ability): void
{
$this->service = Service::ownedByCurrentTeam()
->whereKey($this->service->getKey())
->firstOrFail();
$this->authorize($ability, $this->service);
}
public function render() public function render()
{ {
return view('livewire.project.service.heading', [ return view('livewire.project.service.heading', [

View file

@ -108,10 +108,16 @@ public function mount()
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->query = request()->query(); $this->query = request()->query();
$this->currentRoute = request()->route()->getName(); $this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); $project = currentTeam()
if (! $this->service) { ->projects()
return redirect()->route('dashboard'); ->select('id', 'uuid', 'team_id')
} ->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service); $this->authorize('view', $this->service);
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) { if ($service) {

View file

@ -0,0 +1,66 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ResourceCard extends Component
{
use AuthorizesRequests;
public Service $service;
public ServiceApplication|ServiceDatabase $resource;
public array $parameters = [];
public function getListeners(): array
{
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$team->id},ServiceChecked" => 'refreshResource',
];
}
public function refreshResource(): void
{
$this->resource->refresh();
}
public function restart(): void
{
try {
$this->authorize('update', $this->service);
$this->resource->restart();
$message = $this->resource instanceof ServiceApplication
? 'Service application restarted successfully.'
: 'Service database restarted successfully.';
$this->dispatch('success', $message);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.project.service.resource-card', [
'isApplication' => $this->resource instanceof ServiceApplication,
'isDatabase' => $this->resource instanceof ServiceDatabase,
]);
}
}

View file

@ -21,8 +21,6 @@ class ConfigurationChecker extends Component
public array $configurationDiff = []; public array $configurationDiff = [];
public array $groupedConfigurationChanges = [];
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
public function getListeners(): array public function getListeners(): array
@ -50,21 +48,56 @@ public function refreshConfigurationChanges(): void
$this->configurationChanged(); $this->configurationChanged();
} }
/**
* Members must never see environment variable values, so redact every
* environment-section change before it is serialized to the browser.
*
* @param array<int, array<string, mixed>> $changes
* @return array<int, array<string, mixed>>
*/
private function redactEnvironmentChanges(array $changes, bool $redact): array
{
if (! $redact) {
return $changes;
}
return collect($changes)
->map(function (array $change): array {
if (data_get($change, 'section') !== 'environment') {
return $change;
}
$change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
$change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
$change['old_full_value'] = null;
$change['new_full_value'] = null;
$change['expandable'] = false;
$change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
return $change;
})
->all();
}
public function configurationChanged(): void public function configurationChanged(): void
{ {
$this->resource->refresh(); $this->resource->refresh();
if ($this->resource instanceof Application) { if ($this->resource instanceof Application) {
$diff = $this->resource->pendingDeploymentConfigurationDiff(); $diff = $this->resource->pendingDeploymentConfigurationDiff();
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$array = $diff->toArray();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$this->isConfigurationChanged = $diff->isChanged(); $this->isConfigurationChanged = $diff->isChanged();
$this->configurationDiff = $diff->toArray(); $this->configurationDiff = $array;
$this->groupedConfigurationChanges = $diff->groupedChanges();
return; return;
} }
$this->isConfigurationChanged = $this->resource->isConfigurationChanged(); $this->isConfigurationChanged = $this->resource->isConfigurationChanged();
$this->configurationDiff = []; $this->configurationDiff = [];
$this->groupedConfigurationChanges = [];
} }
} }

View file

@ -110,15 +110,27 @@ public function redeploy(int $network_id, int $server_id)
public function promote(int $network_id, int $server_id) public function promote(int $network_id, int $server_id)
{ {
$main_destination = $this->resource->destination; try {
$this->resource->update([ $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
'destination_id' => $network_id, $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
'destination_type' => StandaloneDocker::class, $this->authorize('update', $this->resource);
]);
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); $this->resource->getConnection()->transaction(function () use ($network, $server) {
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); $main_destination = $this->resource->destination;
$this->refreshServers(); $this->resource->update([
$this->resource->refresh(); 'destination_id' => $network->id,
'destination_type' => StandaloneDocker::class,
]);
$this->resource->additional_networks()
->wherePivot('server_id', $server->id)
->detach($network->id);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
});
$this->resource->refresh();
$this->refreshServers();
} catch (\Exception $e) {
return handleError($e, $this);
}
} }
public function refreshServers() public function refreshServers()
@ -130,8 +142,16 @@ public function refreshServers()
public function addServer(int $network_id, int $server_id) public function addServer(int $network_id, int $server_id)
{ {
$this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); try {
$this->dispatch('refresh'); $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource);
$this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]);
$this->dispatch('refresh');
} catch (\Exception $e) {
return handleError($e, $this);
}
} }
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = []) public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
@ -148,7 +168,9 @@ public function removeServer(int $network_id, int $server_id, $password, $select
} }
$server = Server::ownedByCurrentTeam()->findOrFail($server_id); $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
StopApplicationOneServer::run($this->resource, $server); StopApplicationOneServer::run($this->resource, $server);
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); $this->resource->additional_networks()
->wherePivot('server_id', $server_id)
->detach($network_id);
$this->loadData(); $this->loadData();
$this->dispatch('refresh'); $this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));

View file

@ -7,6 +7,8 @@
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection; use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
class All extends Component class All extends Component
@ -25,6 +27,8 @@ class All extends Component
public string $view = 'normal'; public string $view = 'normal';
public string $search = '';
public bool $is_env_sorting_enabled = false; public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false; public bool $use_build_secrets = false;
@ -35,6 +39,20 @@ class All extends Component
'environmentVariableDeleted' => 'refreshEnvs', 'environmentVariableDeleted' => 'refreshEnvs',
]; ];
public function updatedSearch(): void
{
$this->clearEnvironmentVariableCaches();
}
private function clearEnvironmentVariableCaches(): void
{
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
unset($this->hardcodedEnvironmentVariables);
unset($this->hardcodedEnvironmentVariablesPreview);
unset($this->hasEnvironmentVariables);
}
public function mount() public function mount()
{ {
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
@ -65,8 +83,27 @@ public function instantSave()
public function getEnvironmentVariablesProperty() public function getEnvironmentVariablesProperty()
{ {
$query = $this->resource->environment_variables() return $this->getEnvironmentVariables(false);
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); }
public function getEnvironmentVariablesPreviewProperty()
{
return $this->getEnvironmentVariables(true);
}
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
{
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($withSearch && $this->searchTerm() !== '') {
$escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
}
if ($this->is_env_sorting_enabled) { if ($this->is_env_sorting_enabled) {
$query->orderBy('key'); $query->orderBy('key');
@ -77,18 +114,22 @@ public function getEnvironmentVariablesProperty()
return $query->get(); return $query->get();
} }
public function getEnvironmentVariablesPreviewProperty() private function searchTerm(): string
{ {
$query = $this->resource->environment_variables_preview() return trim($this->search);
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); }
if ($this->is_env_sorting_enabled) { public function getHasEnvironmentVariablesProperty(): bool
$query->orderBy('key'); {
} else { return $this->environmentVariables->isNotEmpty() ||
$query->orderBy('order'); $this->environmentVariablesPreview->isNotEmpty() ||
} $this->hardcodedEnvironmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty();
}
return $query->get(); public function getIsSearchActiveProperty(): bool
{
return $this->searchTerm() !== '';
} }
public function getHardcodedEnvironmentVariablesProperty() public function getHardcodedEnvironmentVariablesProperty()
@ -138,6 +179,12 @@ protected function getHardcodedVariables(bool $isPreview)
return ! in_array($var['key'], $managedKeys); return ! in_array($var['key'], $managedKeys);
}); });
if ($this->searchTerm() !== '') {
$hardcodedVars = $hardcodedVars->filter(function ($var) {
return str($var['key'])->contains($this->searchTerm(), true);
});
}
// Apply sorting based on is_env_sorting_enabled // Apply sorting based on is_env_sorting_enabled
if ($this->is_env_sorting_enabled) { if ($this->is_env_sorting_enabled) {
$hardcodedVars = $hardcodedVars->sortBy('key')->values(); $hardcodedVars = $hardcodedVars->sortBy('key')->values();
@ -149,9 +196,9 @@ protected function getHardcodedVariables(bool $isPreview)
public function getDevView() public function getDevView()
{ {
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables); $this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false));
if ($this->showPreview) { if ($this->showPreview) {
$this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview); $this->variablesPreview = $this->formatEnvironmentVariables($this->getEnvironmentVariables(true, false));
} }
} }
@ -282,9 +329,7 @@ private function handleSingleSubmit($data)
$environment->order = $maxOrder + 1; $environment->order = $maxOrder + 1;
$environment->save(); $environment->save();
// Clear computed property cache to force refresh $this->clearEnvironmentVariableCaches();
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->dispatch('success', 'Environment variable added.'); $this->dispatch('success', 'Environment variable added.');
} }
@ -413,9 +458,7 @@ private function updateOrCreateVariables($isPreview, $variables)
public function refreshEnvs() public function refreshEnvs()
{ {
$this->resource->refresh(); $this->resource->refresh();
// Clear computed property cache to force refresh $this->clearEnvironmentVariableCaches();
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->getDevView(); $this->getDevView();
} }
} }

View file

@ -0,0 +1,91 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class ResourceDetails extends Component
{
use AuthorizesRequests;
public $resource;
public ?string $project_uuid = null;
public ?string $project_name = null;
public ?string $environment_uuid = null;
public ?string $environment_name = null;
public ?string $server_uuid = null;
public ?string $server_name = null;
public array $stack_applications = [];
public array $stack_databases = [];
public function mount()
{
$this->authorize('view', $this->resource);
$environment = $this->resource->environment ?? null;
if ($environment) {
$this->environment_uuid = $environment->uuid;
$this->environment_name = $environment->name;
$project = $environment->project ?? null;
if ($project) {
$this->project_uuid = $project->uuid;
$this->project_name = $project->name;
}
}
$server = $this->resolveServer();
if ($server) {
$this->server_uuid = $server->uuid;
$this->server_name = $server->name;
}
if ($this->resource instanceof Service) {
$this->stack_applications = $this->resource->applications
->map(fn ($app) => [
'name' => $app->human_name ?: $app->name,
'uuid' => $app->uuid,
])
->values()
->all();
$this->stack_databases = $this->resource->databases
->map(fn ($db) => [
'name' => $db->human_name ?: $db->name,
'uuid' => $db->uuid,
])
->values()
->all();
}
}
private function resolveServer()
{
try {
if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) {
return $this->resource->destination->server;
}
if (method_exists($this->resource, 'server') && $this->resource->server) {
return $this->resource->server;
}
} catch (\Throwable $e) {
return null;
}
return null;
}
public function render()
{
return view('livewire.project.shared.resource-details');
}
}

View file

@ -12,6 +12,8 @@ class Terminal extends Component
{ {
public bool $hasShell = true; public bool $hasShell = true;
public bool $isTerminalConnected = false;
private function checkShellAvailability(Server $server, string $container): bool private function checkShellAvailability(Server $server, string $container): bool
{ {
$escapedContainer = escapeshellarg($container); $escapedContainer = escapeshellarg($container);
@ -65,12 +67,20 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$dockerCommand = "sudo {$dockerCommand}"; $dockerCommand = "sudo {$dockerCommand}";
} }
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand); $command = SshMultiplexingHelper::generateSshCommand(
$server,
$dockerCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} else { } else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '. 'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand); $command = SshMultiplexingHelper::generateSshCommand(
$server,
$shellCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} }
// ssh command is sent back to frontend then to websocket // ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here // this is done because the websocket connection is not available here
@ -84,6 +94,23 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$this->dispatch('send-back-command', $command); $this->dispatch('send-back-command', $command);
} }
#[On('terminalConnected')]
public function markTerminalConnected(): void
{
$this->isTerminalConnected = true;
}
#[On('terminalDisconnected')]
public function markTerminalDisconnected(): void
{
$this->isTerminalConnected = false;
}
public function keepTerminalPageAlive(): void
{
$this->isTerminalConnected = true;
}
public function render() public function render()
{ {
return view('livewire.project.shared.terminal'); return view('livewire.project.shared.terminal');

View file

@ -5,6 +5,7 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Laravel\Sanctum\PersonalAccessToken; use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
class ApiTokens extends Component class ApiTokens extends Component
@ -29,8 +30,10 @@ class ApiTokens extends Component
public $isApiEnabled; public $isApiEnabled;
#[Locked]
public bool $canUseRootPermissions = false; public bool $canUseRootPermissions = false;
#[Locked]
public bool $canUseWritePermissions = false; public bool $canUseWritePermissions = false;
public function render() public function render()
@ -54,7 +57,7 @@ private function getTokens()
public function updatedPermissions($permissionToUpdate) public function updatedPermissions($permissionToUpdate)
{ {
// Check if user is trying to use restricted permissions // Check if user is trying to use restricted permissions
if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) { if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use root permissions.'); $this->dispatch('error', 'You do not have permission to use root permissions.');
// Remove root from permissions if it was somehow added // Remove root from permissions if it was somehow added
$this->permissions = array_diff($this->permissions, ['root']); $this->permissions = array_diff($this->permissions, ['root']);
@ -62,7 +65,7 @@ public function updatedPermissions($permissionToUpdate)
return; return;
} }
if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) { if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use write permissions.'); $this->dispatch('error', 'You do not have permission to use write permissions.');
// Remove write permissions if they were somehow added // Remove write permissions if they were somehow added
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
@ -72,7 +75,7 @@ public function updatedPermissions($permissionToUpdate)
if ($permissionToUpdate == 'root') { if ($permissionToUpdate == 'root') {
$this->permissions = ['root']; $this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
$this->permissions[] = 'read'; $this->permissions[] = 'read';
} elseif ($permissionToUpdate == 'deploy') { } elseif ($permissionToUpdate == 'deploy') {
$this->permissions = ['deploy']; $this->permissions = ['deploy'];
@ -90,11 +93,11 @@ public function addNewToken()
$this->authorize('create', PersonalAccessToken::class); $this->authorize('create', PersonalAccessToken::class);
// Validate permissions based on user role // Validate permissions based on user role
if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) { if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.'); throw new \Exception('You do not have permission to create tokens with root permissions.');
} }
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) { if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.'); throw new \Exception('You do not have permission to create tokens with write permissions.');
} }

View file

@ -2,11 +2,15 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class Charts extends Component class Charts extends Component
{ {
use AuthorizesRequests;
public Server $server; public Server $server;
public $chartId = 'server'; public $chartId = 'server';
@ -28,6 +32,29 @@ public function mount(string $server_uuid)
} }
} }
public function toggleMetrics(): void
{
try {
$this->authorize('update', $this->server);
$this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled;
$this->server->settings->save();
$this->server->refresh();
if ($this->server->isMetricsEnabled()) {
StartSentinel::run($this->server, true);
$this->dispatch('success', 'Metrics enabled. Starting Sentinel.');
$this->dispatch('refreshServerShow');
$this->redirect(route('server.metrics', ['server_uuid' => $this->server->uuid]), navigate: true);
} else {
$this->server->restartSentinel();
$this->dispatch('success', 'Metrics disabled. Restarting Sentinel.');
$this->dispatch('refreshServerShow');
}
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function pollData() public function pollData()
{ {
if ($this->poll || $this->interval <= 10) { if ($this->poll || $this->interval <= 10) {

View file

@ -45,7 +45,7 @@ public function add($name)
} else { } else {
SwarmDocker::create([ SwarmDocker::create([
'name' => $this->server->name.'-'.$name, 'name' => $this->server->name.'-'.$name,
'network' => $this->name, 'network' => $name,
'server_id' => $this->server->id, 'server_id' => $this->server->id,
]); ]);
} }

View file

@ -57,7 +57,7 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'ip' => ['required', 'string', new ValidServerIp], 'ip' => ['required', 'string', new ValidServerIp],
'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'], 'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535', 'port' => 'required|integer|between:1,65535',
'is_build_server' => 'required|boolean', 'is_build_server' => 'required|boolean',
]; ];
@ -75,6 +75,7 @@ protected function messages(): array
'ip.string' => 'The IP Address/Domain must be a string.', 'ip.string' => 'The IP Address/Domain must be a string.',
'user.required' => 'The User field is required.', 'user.required' => 'The User field is required.',
'user.string' => 'The User field must be a string.', 'user.string' => 'The User field must be a string.',
...ValidationPatterns::serverUsernameMessages(),
'port.required' => 'The Port field is required.', 'port.required' => 'The Port field is required.',
'port.integer' => 'The Port field must be an integer.', 'port.integer' => 'The Port field must be an integer.',
'port.between' => 'The Port field must be between 1 and 65535.', 'port.between' => 'The Port field must be between 1 and 65535.',

View file

@ -28,12 +28,11 @@ public function delete(string $fileName)
// Decode filename: pipes are used to encode dots for Livewire property binding // Decode filename: pipes are used to encode dots for Livewire property binding
// (e.g., 'my|service.yaml' -> 'my.service.yaml') // (e.g., 'my|service.yaml' -> 'my.service.yaml')
// This must happen BEFORE validation because validateShellSafePath() correctly // This must happen BEFORE validation because validateFilenameSafe()
// rejects pipe characters as dangerous shell metacharacters // rejects pipe characters through validateShellSafePath().
$file = str_replace('|', '.', $fileName); $file = str_replace('|', '.', $fileName);
// Validate filename to prevent command injection validateFilenameSafe($file, 'proxy configuration filename');
validateShellSafePath($file, 'proxy configuration filename');
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.'); $this->dispatch('error', 'Cannot delete Caddyfile.');

View file

@ -43,8 +43,7 @@ public function addDynamicConfiguration()
'value' => 'required', 'value' => 'required',
]); ]);
// Additional security validation to prevent command injection validateFilenameSafe($this->fileName, 'proxy configuration filename');
validateShellSafePath($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) { if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();

Some files were not shown because too many files have changed in this diff Show more