This commit is contained in:
Tyler Westbrook 2026-03-11 02:54:50 +00:00 committed by GitHub
commit f74d4a4a2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 5566 additions and 69 deletions

View file

@ -3,6 +3,7 @@
namespace App\Actions\Database;
use App\Models\ServiceDatabase;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@ -24,7 +25,7 @@ class StartDatabaseProxy
{
$databaseType = $database->database_type;
$network = data_get($database, 'destination.network');
$server = data_get($database, 'destination.server');
$deploymentServer = data_get($database, 'destination.server');
$containerName = data_get($database, 'uuid');
$proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
@ -32,7 +33,7 @@ class StartDatabaseProxy
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$databaseType = $database->databaseType();
$network = $database->service->uuid;
$server = data_get($database, 'service.destination.server');
$deploymentServer = data_get($database, 'service.destination.server') ?? data_get($database, 'service.server');
$containerName = "{$database->name}-{$database->service->uuid}";
}
$internalPort = match ($databaseType) {
@ -50,11 +51,36 @@ class StartDatabaseProxy
};
}
$configuration_dir = database_proxy_dir($database->uuid);
$host_configuration_dir = $configuration_dir;
if (isDev()) {
$host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
if (! $deploymentServer instanceof Server) {
$this->logWarning(sprintf(
'Database proxy for %s is skipped because deployment server is missing.',
$database->uuid
));
return;
}
$proxyServer = $deploymentServer;
$upstreamTarget = "{$containerName}:{$internalPort}";
$proxyNetwork = $network;
$edgeProxyServer = $this->resolveEdgeProxyServerForTeamId($this->resolveDatabaseTeamId($database));
if ($edgeProxyServer instanceof Server && $edgeProxyServer->id !== $deploymentServer->id) {
$remoteHost = $this->resolveRemoteHost($deploymentServer);
if (! is_null($remoteHost)) {
$proxyServer = $edgeProxyServer;
$upstreamTarget = "{$remoteHost}:{$internalPort}";
$proxyNetwork = null;
} else {
$this->logWarning(sprintf(
'Database proxy for %s is falling back to deployment server because remote host for edge forwarding is missing.',
$database->uuid
));
}
}
$configuration_dir = $this->resolveConfigurationDirectory($database->uuid);
$host_configuration_dir = $this->resolveHostConfigurationDirectory($database->uuid, $configuration_dir);
$timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout);
$nginxconf = <<<EOF
user nginx;
@ -68,62 +94,69 @@ class StartDatabaseProxy
stream {
server {
listen $database->public_port;
proxy_pass $containerName:$internalPort;
proxy_pass $upstreamTarget;
$timeoutConfig
}
}
EOF;
$docker_compose = [
'services' => [
$proxyContainerName => [
'image' => 'nginx:stable-alpine',
'container_name' => $proxyContainerName,
'restart' => RESTART_MODE,
'ports' => [
"$database->public_port:$database->public_port",
],
'networks' => [
$network,
],
'volumes' => [
[
'type' => 'bind',
'source' => "$host_configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf',
],
],
'healthcheck' => [
'test' => [
'CMD-SHELL',
'stat /etc/nginx/nginx.conf || exit 1',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 3,
'start_period' => '1s',
],
$proxyServiceCompose = [
'image' => 'nginx:stable-alpine',
'container_name' => $proxyContainerName,
'restart' => RESTART_MODE,
'ports' => [
"$database->public_port:$database->public_port",
],
'volumes' => [
[
'type' => 'bind',
'source' => "$host_configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf',
],
],
'networks' => [
$network => [
'external' => true,
'name' => $network,
'attachable' => true,
'healthcheck' => [
'test' => [
'CMD-SHELL',
'stat /etc/nginx/nginx.conf || exit 1',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 3,
'start_period' => '1s',
],
];
$docker_compose = [
'services' => [
$proxyContainerName => $proxyServiceCompose,
],
];
if (filled($proxyNetwork)) {
$docker_compose['services'][$proxyContainerName]['networks'] = [$proxyNetwork];
$docker_compose['networks'] = [
$proxyNetwork => [
'external' => true,
'name' => $proxyNetwork,
'attachable' => true,
],
];
}
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
$this->runRemoteCommands(["docker rm -f $proxyContainerName"], $deploymentServer, false);
if ($proxyServer->id !== $deploymentServer->id) {
$this->runRemoteCommands(["docker rm -f $proxyContainerName"], $proxyServer, false);
}
try {
instant_remote_process([
$this->runRemoteCommands([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
], $proxyServer);
} catch (\RuntimeException $e) {
if ($this->isNonTransientError($e->getMessage())) {
$database->update(['is_public' => false]);
@ -134,7 +167,7 @@ class StartDatabaseProxy
$team?->notify(
new \App\Notifications\Container\ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
$proxyServer,
)
);
@ -147,6 +180,140 @@ class StartDatabaseProxy
}
}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
return instant_remote_process($commands, $server, $throwError);
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return database_proxy_dir($databaseUuid);
}
protected function resolveHostConfigurationDirectory(string $databaseUuid, ?string $configurationDirectory = null): string
{
$configurationDirectory ??= $this->resolveConfigurationDirectory($databaseUuid);
if ($this->isDevelopmentEnvironment()) {
return '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$databaseUuid.'/proxy';
}
return $configurationDirectory;
}
protected function isDevelopmentEnvironment(): bool
{
if (app()->bound('config')) {
return isDev();
}
$appEnv = $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? getenv('APP_ENV');
return $appEnv === 'local';
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
if (is_null($teamId)) {
return null;
}
return Server::query()
->where('team_id', $teamId)
->whereRelation('settings', 'is_master_domain_router_enabled', true)
->orderBy('id')
->first();
}
private function resolveDatabaseTeamId(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database): ?int
{
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$teamId = data_get($database, 'service.environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
}
$teamId = data_get($database, 'environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
$teamId = data_get($database, 'team.id');
if (! is_null($teamId)) {
return (int) $teamId;
}
return null;
}
private function resolveRemoteHost(Server $deploymentServer): ?string
{
$candidates = [
data_get($deploymentServer, 'proxy.wireguard_ip'),
data_get($deploymentServer, 'proxy.wg_ip'),
data_get($deploymentServer, 'proxy.tunnel_ip'),
data_get($deploymentServer, 'proxy.tunnel_host'),
data_get($deploymentServer, 'proxy.tunnel_domain'),
data_get($deploymentServer, 'ip'),
];
foreach ($candidates as $candidate) {
$normalizedHost = $this->normalizeRemoteHost((string) $candidate);
if (! is_null($normalizedHost)) {
return $normalizedHost;
}
}
return null;
}
private function normalizeRemoteHost(string $rawHost): ?string
{
$host = trim($rawHost);
if ($host === '') {
return null;
}
if (str_starts_with($host, 'http://') || str_starts_with($host, 'https://')) {
$parsedHost = parse_url($host, PHP_URL_HOST);
$host = is_string($parsedHost) ? $parsedHost : '';
} elseif (str_contains($host, '/')) {
$parsedHost = parse_url('http://'.$host, PHP_URL_HOST);
$host = is_string($parsedHost) ? $parsedHost : '';
}
$host = trim($host, '[]');
if ($host === '') {
return null;
}
if (str_contains($host, ':') && ! filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$parsedHost = parse_url('http://'.$host, PHP_URL_HOST);
$host = is_string($parsedHost) ? $parsedHost : '';
}
if ($host === '') {
return null;
}
if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return '['.$host.']';
}
return $host;
}
protected function logWarning(string $message): void
{
if (app()->bound('log')) {
app('log')->warning($message);
return;
}
error_log($message);
}
private function isNonTransientError(string $message): bool
{
$nonTransientPatterns = [

View file

@ -4,6 +4,7 @@ namespace App\Actions\Database;
use App\Events\DatabaseProxyStopped;
use App\Models\ServiceDatabase;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@ -22,16 +23,69 @@ class StopDatabaseProxy
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|ServiceDatabase|StandaloneDragonfly|StandaloneClickhouse $database)
{
$server = data_get($database, 'destination.server');
$deploymentServer = data_get($database, 'destination.server');
$uuid = $database->uuid;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$server = data_get($database, 'service.server');
$deploymentServer = data_get($database, 'service.destination.server') ?? data_get($database, 'service.server');
}
if (! $deploymentServer instanceof Server) {
return;
}
$this->runRemoteCommands(["docker rm -f {$uuid}-proxy"], $deploymentServer, false);
$edgeProxyServer = $this->resolveEdgeProxyServerForTeamId($this->resolveDatabaseTeamId($database));
if ($edgeProxyServer instanceof Server && $edgeProxyServer->id !== $deploymentServer->id) {
$this->runRemoteCommands(["docker rm -f {$uuid}-proxy"], $edgeProxyServer, false);
}
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
$database->save();
DatabaseProxyStopped::dispatch();
$this->dispatchDatabaseProxyStoppedEvent();
}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
return instant_remote_process($commands, $server, $throwError);
}
protected function dispatchDatabaseProxyStoppedEvent(): void
{
DatabaseProxyStopped::dispatch();
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
if (is_null($teamId)) {
return null;
}
return Server::query()
->where('team_id', $teamId)
->whereRelation('settings', 'is_master_domain_router_enabled', true)
->orderBy('id')
->first();
}
private function resolveDatabaseTeamId(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|ServiceDatabase|StandaloneDragonfly|StandaloneClickhouse $database): ?int
{
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$teamId = data_get($database, 'service.environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
}
$teamId = data_get($database, 'environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
$teamId = data_get($database, 'team.id');
if (! is_null($teamId)) {
return (int) $teamId;
}
return null;
}
}

View file

@ -4,6 +4,8 @@ namespace App\Actions\Service;
use App\Actions\Server\CleanupDocker;
use App\Models\Service;
use App\Services\EdgeProxyRemotePortForwardService;
use App\Services\EdgeProxyRemoteRouteService;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
@ -55,6 +57,23 @@ class DeleteService
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage());
} finally {
try {
app(EdgeProxyRemoteRouteService::class)->deleteService($service);
} catch (\Throwable $exception) {
Log::warning('Failed to delete edge proxy route file for service.', [
'service_uuid' => $service->uuid,
'error' => $exception->getMessage(),
]);
}
try {
app(EdgeProxyRemotePortForwardService::class)->deleteService($service);
} catch (\Throwable $exception) {
Log::warning('Failed to delete edge port proxy for service.', [
'service_uuid' => $service->uuid,
'error' => $exception->getMessage(),
]);
}
if ($deleteConfigurations) {
$service->deleteConfigurations();
}

View file

@ -3,6 +3,9 @@
namespace App\Actions\Service;
use App\Models\Service;
use App\Services\EdgeProxyRemotePortForwardService;
use App\Services\EdgeProxyRemoteRouteService;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@ -15,6 +18,26 @@ class StartService
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{
$service->parse();
$edgeRoutingWarnings = [];
$edgePortForwardWarnings = [];
try {
$edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)->syncService($service);
} catch (\Throwable $exception) {
Log::warning('Failed to sync edge proxy route for service start.', [
'service_uuid' => $service->uuid,
'error' => $exception->getMessage(),
]);
$edgeRoutingWarnings[] = 'Failed to sync edge proxy route configuration. Check edge proxy connectivity and server settings.';
}
try {
$edgePortForwardWarnings = app(EdgeProxyRemotePortForwardService::class)->syncService($service);
} catch (\Throwable $exception) {
Log::warning('Failed to sync edge port forwarding for service start.', [
'service_uuid' => $service->uuid,
'error' => $exception->getMessage(),
]);
$edgePortForwardWarnings[] = 'Failed to sync edge port forwarding configuration. Check edge proxy connectivity, published ports, and server settings.';
}
if ($stopBeforeStart) {
StopService::run(service: $service, dockerCleanup: false);
}
@ -23,6 +46,12 @@ class StartService
$workdir = $service->workdir();
// $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
foreach ($edgeRoutingWarnings as $warning) {
$commands[] = 'echo '.escapeshellarg("Edge proxy routing warning: {$warning}");
}
foreach ($edgePortForwardWarnings as $warning) {
$commands[] = 'echo '.escapeshellarg("Edge port forwarding warning: {$warning}");
}
// Ensure .env exists in the correct directory before docker compose tries to load it
// This is defensive programming - saveComposeConfigs() already creates it,
// but we guarantee it here in case of any edge cases or manual deployments

View file

@ -19,6 +19,8 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Services\EdgeProxyRemotePortForwardService;
use App\Services\EdgeProxyRemoteRouteService;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
use Carbon\Carbon;
@ -508,6 +510,37 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} catch (\Exception $e) {
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
// Intentionally skip preview deployments here; preview routing has separate lifecycle/hostnames.
if ($this->pull_request_id === 0) {
try {
$edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)
->syncApplicationOnDeploymentServer($this->application, $this->mainServer);
foreach ($edgeRoutingWarnings as $warning) {
$this->application_deployment_queue->addLogEntry("Edge proxy routing warning: {$warning}", 'stderr');
}
} catch (\Throwable $e) {
\Log::warning('Failed to sync edge proxy route for application deployment '.$this->deployment_uuid.': '.$e->getMessage());
$this->application_deployment_queue->addLogEntry(
'Edge proxy routing warning: Failed to sync edge proxy route configuration. Check master domain server settings and edge proxy connectivity.',
'stderr'
);
}
try {
$edgePortForwardWarnings = app(EdgeProxyRemotePortForwardService::class)
->syncApplicationOnDeploymentServer($this->application, $this->mainServer);
foreach ($edgePortForwardWarnings as $warning) {
$this->application_deployment_queue->addLogEntry("Edge port forwarding warning: {$warning}", 'stderr');
}
} catch (\Throwable $e) {
\Log::warning('Failed to sync edge port forwarding for application deployment '.$this->deployment_uuid.': '.$e->getMessage());
$this->application_deployment_queue->addLogEntry(
'Edge port forwarding warning: Failed to sync edge port forwarding configuration. Check master domain server settings, published ports, and edge proxy connectivity.',
'stderr'
);
}
}
}
private function deploy_simple_dockerfile()

View file

@ -18,6 +18,8 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Services\EdgeProxyRemotePortForwardService;
use App\Services\EdgeProxyRemoteRouteService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -102,6 +104,19 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
} catch (\Throwable $e) {
throw $e;
} finally {
if ($this->resource instanceof Application) {
try {
app(EdgeProxyRemoteRouteService::class)->deleteApplication($this->resource);
} catch (\Throwable $e) {
\Log::warning('Failed to delete edge proxy route file for application '.$this->resource->uuid.': '.$e->getMessage());
}
try {
app(EdgeProxyRemotePortForwardService::class)->deleteApplication($this->resource);
} catch (\Throwable $e) {
\Log::warning('Failed to delete edge port proxy for application '.$this->resource->uuid.': '.$e->getMessage());
}
}
$this->resource->forceDelete();
if ($this->dockerCleanup) {
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');

View file

@ -4,6 +4,7 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Enums\ProxyTypes;
use App\Events\ServerReachabilityChanged;
use App\Models\CloudProviderToken;
use App\Models\Server;
@ -46,9 +47,17 @@ class Show extends Component
public bool $isBuildServer;
public bool $isMasterDomainRouterEnabled;
#[Locked]
public bool $isBuildServerLocked = false;
#[Locked]
public bool $isMasterDomainRouterLocked = false;
#[Locked]
public ?string $masterDomainRouterLockMessage = null;
public bool $isMetricsEnabled;
public string $sentinelToken;
@ -117,6 +126,7 @@ class Show extends Component
'isSwarmManager' => 'required',
'isSwarmWorker' => 'required',
'isBuildServer' => 'required',
'isMasterDomainRouterEnabled' => 'required',
'isMetricsEnabled' => 'required',
'sentinelToken' => 'required',
'sentinelUpdatedAt' => 'nullable',
@ -163,6 +173,7 @@ class Show extends Component
if (! $this->server->isEmpty()) {
$this->isBuildServerLocked = true;
}
$this->refreshMasterDomainRouterLockState();
// Load saved Hetzner status and validation state
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
@ -214,6 +225,7 @@ class Show extends Component
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
$this->server->settings->is_build_server = $this->isBuildServer;
$this->server->settings->is_master_domain_router_enabled = $this->isMasterDomainRouterEnabled;
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
$this->server->settings->sentinel_token = $this->sentinelToken;
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
@ -244,6 +256,7 @@ class Show extends Component
$this->isSwarmManager = $this->server->settings->is_swarm_manager;
$this->isSwarmWorker = $this->server->settings->is_swarm_worker;
$this->isBuildServer = $this->server->settings->is_build_server;
$this->isMasterDomainRouterEnabled = $this->server->settings->is_master_domain_router_enabled;
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
$this->sentinelToken = $this->server->settings->sentinel_token;
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
@ -256,6 +269,8 @@ class Show extends Component
$this->serverTimezone = $this->server->settings->server_timezone;
$this->isValidating = $this->server->is_validating ?? false;
}
$this->refreshMasterDomainRouterLockState();
}
public function refresh()
@ -354,6 +369,25 @@ class Show extends Component
}
}
public function updatedIsMasterDomainRouterEnabled($value)
{
try {
$this->authorize('update', $this->server);
$this->refreshMasterDomainRouterLockState();
if ($value === true && $this->isMasterDomainRouterLocked) {
$this->isMasterDomainRouterEnabled = false;
$this->dispatch('error', $this->masterDomainRouterLockMessage ?? 'Another server in this team is already selected as the master domain router.');
return;
}
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function updatedIsSentinelEnabled($value)
{
try {
@ -493,6 +527,34 @@ class Show extends Component
}
}
private function refreshMasterDomainRouterLockState(): void
{
$this->isMasterDomainRouterLocked = false;
$this->masterDomainRouterLockMessage = null;
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return;
}
$this->server->loadMissing('settings');
if ($this->server->settings->is_master_domain_router_enabled) {
return;
}
$masterDomainServerExists = Server::query()
->where('team_id', $this->server->team_id)
->where('id', '!=', $this->server->id)
->whereRelation('settings', 'is_master_domain_router_enabled', true)
->exists();
if (! $masterDomainServerExists) {
return;
}
$this->isMasterDomainRouterLocked = true;
$this->masterDomainRouterLockMessage = 'Disabled because another server in this team is already selected as the master domain router. Disable it there first.';
}
public function loadHetznerTokens(): void
{
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()

View file

@ -19,6 +19,7 @@ use OpenApi\Attributes as OA;
'force_server_cleanup' => ['type' => 'boolean'],
'is_build_server' => ['type' => 'boolean'],
'is_cloudflare_tunnel' => ['type' => 'boolean'],
'is_master_domain_router_enabled' => ['type' => 'boolean'],
'is_jump_server' => ['type' => 'boolean'],
'is_logdrain_axiom_enabled' => ['type' => 'boolean'],
'is_logdrain_custom_enabled' => ['type' => 'boolean'],
@ -62,6 +63,7 @@ class ServerSetting extends Model
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'is_master_domain_router_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
];
@ -90,6 +92,30 @@ class ServerSetting extends Model
$settings->server->restartSentinel();
}
});
static::saving(function ($setting) {
$setting->ensureSingleMasterDomainRouterEnabled();
});
}
private function ensureSingleMasterDomainRouterEnabled(): void
{
if (! $this->is_master_domain_router_enabled || is_null($this->server_id)) {
return;
}
$teamId = Server::query()
->whereKey($this->server_id)
->value('team_id');
if (is_null($teamId)) {
return;
}
static::query()
->where('server_id', '!=', $this->server_id)
->where('is_master_domain_router_enabled', true)
->whereHas('server', fn ($query) => $query->where('team_id', $teamId))
->update(['is_master_domain_router_enabled' => false]);
}
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)

View file

@ -0,0 +1,868 @@
<?php
namespace App\Services;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Support\Collection;
use Symfony\Component\Yaml\Yaml;
class EdgeProxyRemotePortForwardService
{
private const array RESERVED_EDGE_PORTS = [80, 443];
public function syncService(Service $service): array
{
$deploymentServer = $this->resolveServiceDeploymentServer($service);
if (! $deploymentServer instanceof Server) {
return [];
}
return $this->syncServiceWithServers(
$service,
$this->resolveEdgeProxyServerByTeamId($this->extractServiceTeamId($service)),
$deploymentServer
);
}
public function syncServiceWithServers(Service $service, ?Server $edgeProxyServer, Server $deploymentServer): array
{
return $this->syncResourcePortProxy(
resourceType: 'service',
resourceUuid: $service->uuid,
edgeProxyServer: $edgeProxyServer,
deploymentServer: $deploymentServer,
publishedPortMappings: $this->servicePublishedPortMappings($service)
);
}
public function syncApplication(Application $application): array
{
$deploymentServer = $this->resolveApplicationDeploymentServer($application);
if (! $deploymentServer instanceof Server) {
return [];
}
return $this->syncApplicationWithServers(
$application,
$this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application)),
$deploymentServer
);
}
public function syncApplicationOnDeploymentServer(Application $application, Server $deploymentServer): array
{
return $this->syncApplicationWithServers(
$application,
$this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application)),
$deploymentServer
);
}
public function syncApplicationWithServers(Application $application, ?Server $edgeProxyServer, Server $deploymentServer): array
{
return $this->syncResourcePortProxy(
resourceType: 'application',
resourceUuid: $application->uuid,
edgeProxyServer: $edgeProxyServer,
deploymentServer: $deploymentServer,
publishedPortMappings: $this->applicationPublishedPortMappings($application)
);
}
public function deleteService(Service $service): void
{
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractServiceTeamId($service));
if (! $edgeProxyServer instanceof Server) {
return;
}
$this->deleteServiceWithServer($service, $edgeProxyServer);
}
public function deleteServiceWithServer(Service $service, Server $edgeProxyServer): void
{
$this->deleteResourcePortProxy($edgeProxyServer, 'service', $service->uuid);
}
public function deleteApplication(Application $application): void
{
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application));
if (! $edgeProxyServer instanceof Server) {
return;
}
$this->deleteApplicationWithServer($application, $edgeProxyServer);
}
public function deleteApplicationWithServer(Application $application, Server $edgeProxyServer): void
{
$this->deleteResourcePortProxy($edgeProxyServer, 'application', $application->uuid);
}
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
return instant_remote_process($commands, $server, $throwError);
}
protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server
{
if (is_null($teamId)) {
return null;
}
return Server::query()
->where('team_id', $teamId)
->whereRelation('settings', 'is_master_domain_router_enabled', true)
->orderBy('id')
->first();
}
protected function logWarning(string $message): void
{
if (app()->bound('log')) {
app('log')->warning($message);
return;
}
error_log($message);
}
private function syncResourcePortProxy(
string $resourceType,
string $resourceUuid,
?Server $edgeProxyServer,
Server $deploymentServer,
Collection $publishedPortMappings
): array {
if (! $edgeProxyServer instanceof Server) {
return [];
}
if ($edgeProxyServer->id === $deploymentServer->id) {
$this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid);
return [];
}
if ($publishedPortMappings->isEmpty()) {
$this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid);
return [];
}
$warnings = [];
$publishedPortMappings = $this->filterReservedEdgePorts($resourceType, $resourceUuid, $publishedPortMappings, $warnings);
if ($publishedPortMappings->isEmpty()) {
$this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid);
foreach ($warnings as $warning) {
$this->logWarning($warning);
}
return $warnings;
}
$remoteHost = $this->resolveRemoteHost($deploymentServer);
if (is_null($remoteHost)) {
$warning = sprintf(
'Edge port forwarding skipped for %s %s: remote host is missing. Configure a tunnel host (proxy.wireguard_ip/proxy.wg_ip/proxy.tunnel_ip/proxy.tunnel_host) or set the server IP/domain.',
$resourceType,
$resourceUuid
);
$this->logWarning($warning);
$this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid);
$warnings[] = $warning;
return $warnings;
}
try {
$this->writeResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid, $remoteHost, $publishedPortMappings);
} catch (\Throwable $exception) {
$warning = sprintf(
'Edge port forwarding warning for %s %s: failed to start edge port proxy (%s). Check whether one of the published ports is already in use on the edge server.',
$resourceType,
$resourceUuid,
$exception->getMessage()
);
$this->logWarning($warning);
$warnings[] = $warning;
}
foreach ($warnings as $warning) {
$this->logWarning($warning);
}
return $warnings;
}
private function servicePublishedPortMappings(Service $service): Collection
{
return $this->composePublishedPortMappings(
$this->parseServiceCompose($service),
$this->serviceEnvironmentMap($service)
);
}
private function applicationPublishedPortMappings(Application $application): Collection
{
$environmentMap = $this->applicationEnvironmentMap($application);
if ($application->build_pack === 'dockercompose') {
return $this->composePublishedPortMappings(
$this->parseApplicationCompose($application),
$environmentMap
);
}
return $this->parsePublishedPortMappings($application->ports_mappings_array ?? [], $environmentMap);
}
private function composePublishedPortMappings(array $compose, array $environmentMap): Collection
{
$services = data_get($compose, 'services', []);
if (! is_array($services) || empty($services)) {
return collect();
}
return collect($services)
->filter(fn (mixed $serviceConfig) => is_array($serviceConfig))
->flatMap(function (array $serviceConfig) use ($environmentMap) {
return $this->parsePublishedPortMappings(
(array) data_get($serviceConfig, 'ports', []),
$this->mergeComposeEnvironmentMap($serviceConfig, $environmentMap)
);
})
->unique(fn (array $mapping) => $mapping['protocol'].':'.$mapping['published'])
->sortBy(fn (array $mapping) => sprintf('%05d:%s', $mapping['published'], $mapping['protocol']))
->values();
}
private function parsePublishedPortMappings(array $ports, array $environmentMap): Collection
{
$mappings = collect();
foreach ($ports as $portDefinition) {
if (is_array($portDefinition)) {
$protocol = strtolower((string) data_get($portDefinition, 'protocol', 'tcp'));
if (! in_array($protocol, ['tcp', 'udp'], true)) {
continue;
}
$published = $this->resolvePortValue(data_get($portDefinition, 'published'), $environmentMap);
if (! is_null($published)) {
$mappings->push([
'published' => $published,
'protocol' => $protocol,
]);
}
continue;
}
if (is_string($portDefinition) || is_int($portDefinition)) {
$mapping = $this->parsePublishedPortMappingFromString((string) $portDefinition, $environmentMap);
if (! is_null($mapping) && ! is_null($mapping['published'])) {
$mappings->push($mapping);
}
}
}
return $mappings->values();
}
private function parsePublishedPortMappingFromString(string $portDefinition, array $environmentMap): ?array
{
$normalizedPortDefinition = trim($portDefinition);
if ($normalizedPortDefinition === '') {
return null;
}
$protocol = 'tcp';
if (preg_match('/\/(tcp|udp)$/i', $normalizedPortDefinition, $protocolMatches)) {
$protocol = strtolower($protocolMatches[1]);
$normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition;
}
if (! str_contains($normalizedPortDefinition, ':')) {
return [
'published' => null,
'protocol' => $protocol,
];
}
$segments = $this->splitPortDefinitionSegments($normalizedPortDefinition);
if (count($segments) < 2) {
return null;
}
array_pop($segments);
$publishedPort = $this->resolvePortValue(array_pop($segments), $environmentMap);
return [
'published' => $publishedPort,
'protocol' => $protocol,
];
}
private function filterReservedEdgePorts(string $resourceType, string $resourceUuid, Collection $publishedPortMappings, array &$warnings): Collection
{
return $publishedPortMappings
->reject(function (array $mapping) use ($resourceType, $resourceUuid, &$warnings) {
if (! in_array($mapping['published'], self::RESERVED_EDGE_PORTS, true)) {
return false;
}
$warnings[] = sprintf(
'Edge port forwarding skipped for %s %s on port %d/%s: this port is reserved on the edge server for HTTP/HTTPS proxying.',
$resourceType,
$resourceUuid,
$mapping['published'],
$mapping['protocol']
);
return true;
})
->values();
}
private function writeResourcePortProxy(
Server $edgeProxyServer,
string $resourceType,
string $resourceUuid,
string $remoteHost,
Collection $publishedPortMappings
): void {
$containerName = $this->proxyContainerName($resourceType, $resourceUuid);
$configurationDirectory = $this->configurationDirectory($resourceType, $resourceUuid);
$escapedConfigurationDirectory = escapeshellarg($configurationDirectory);
$escapedNginxPath = escapeshellarg($configurationDirectory.'/nginx.conf');
$escapedComposePath = escapeshellarg($configurationDirectory.'/docker-compose.yaml');
$escapedContainerName = escapeshellarg($containerName);
$nginxConfig = base64_encode($this->generateNginxStreamConfig($remoteHost, $publishedPortMappings));
$dockerCompose = base64_encode(Yaml::dump(
$this->generateDockerCompose($containerName, $configurationDirectory, $publishedPortMappings),
6,
2
));
$this->runRemoteCommands($edgeProxyServer, [
"docker rm -f $escapedContainerName >/dev/null 2>&1 || true",
"mkdir -p $escapedConfigurationDirectory",
"echo '{$nginxConfig}' | base64 -d | tee $escapedNginxPath > /dev/null",
"echo '{$dockerCompose}' | base64 -d | tee $escapedComposePath > /dev/null",
"docker compose --project-directory $escapedConfigurationDirectory pull",
"docker compose --project-directory $escapedConfigurationDirectory up -d",
]);
}
private function deleteResourcePortProxy(Server $edgeProxyServer, string $resourceType, string $resourceUuid): void
{
$escapedContainerName = escapeshellarg($this->proxyContainerName($resourceType, $resourceUuid));
$this->runRemoteCommands($edgeProxyServer, [
"docker rm -f $escapedContainerName",
], false);
}
private function generateNginxStreamConfig(string $remoteHost, Collection $publishedPortMappings): string
{
$serverBlocks = $publishedPortMappings
->map(function (array $mapping) use ($remoteHost) {
$listen = (string) $mapping['published'];
if ($mapping['protocol'] === 'udp') {
$listen .= ' udp';
}
return <<<EOF
server {
listen {$listen};
proxy_pass {$remoteHost}:{$mapping['published']};
}
EOF;
})
->implode("\n");
return <<<EOF
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
events {
worker_connections 1024;
}
stream {
{$serverBlocks}
}
EOF;
}
private function generateDockerCompose(string $containerName, string $configurationDirectory, Collection $publishedPortMappings): array
{
$ports = $publishedPortMappings
->map(function (array $mapping) {
$port = "{$mapping['published']}:{$mapping['published']}";
return $mapping['protocol'] === 'udp' ? "{$port}/udp" : $port;
})
->values()
->all();
return [
'services' => [
$containerName => [
'image' => 'nginx:stable-alpine',
'container_name' => $containerName,
'restart' => RESTART_MODE,
'ports' => $ports,
'volumes' => [[
'type' => 'bind',
'source' => $configurationDirectory.'/nginx.conf',
'target' => '/etc/nginx/nginx.conf',
]],
'healthcheck' => [
'test' => [
'CMD-SHELL',
'stat /etc/nginx/nginx.conf || exit 1',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 3,
'start_period' => '1s',
],
],
],
];
}
private function configurationDirectory(string $resourceType, string $resourceUuid): string
{
return match ($resourceType) {
'application' => application_configuration_dir()."/{$resourceUuid}/edge-port-proxy",
'service' => service_configuration_dir()."/{$resourceUuid}/edge-port-proxy",
default => base_configuration_dir()."/{$resourceType}/{$resourceUuid}/edge-port-proxy",
};
}
private function proxyContainerName(string $resourceType, string $resourceUuid): string
{
return "{$resourceType}-{$resourceUuid}-edge-port-proxy";
}
private function resolveServiceDeploymentServer(Service $service): ?Server
{
$server = data_get($service, 'server');
if ($server instanceof Server) {
return $server;
}
$server = data_get($service, 'destination.server');
if ($server instanceof Server) {
return $server;
}
if ($service->exists && ! is_null($service->server_id)) {
return Server::query()->find($service->server_id);
}
return null;
}
private function resolveApplicationDeploymentServer(Application $application): ?Server
{
$server = data_get($application, 'server');
if ($server instanceof Server) {
return $server;
}
$server = data_get($application, 'destination.server');
if ($server instanceof Server) {
return $server;
}
if ($application->exists) {
$application->loadMissing('destination.server');
$server = data_get($application, 'destination.server');
if ($server instanceof Server) {
return $server;
}
}
return null;
}
private function extractServiceTeamId(Service $service): ?int
{
$teamId = data_get($service, 'environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
if ($service->exists) {
$service->loadMissing('environment.project');
$teamId = data_get($service, 'environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
}
return null;
}
private function extractApplicationTeamId(Application $application): ?int
{
$teamId = data_get($application, 'environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
if ($application->exists) {
$application->loadMissing('environment.project');
$teamId = data_get($application, 'environment.project.team_id');
if (! is_null($teamId)) {
return (int) $teamId;
}
}
return null;
}
private function resolveRemoteHost(Server $deploymentServer): ?string
{
$candidates = [
data_get($deploymentServer, 'proxy.wireguard_ip'),
data_get($deploymentServer, 'proxy.wg_ip'),
data_get($deploymentServer, 'proxy.tunnel_ip'),
data_get($deploymentServer, 'proxy.tunnel_host'),
data_get($deploymentServer, 'proxy.tunnel_domain'),
data_get($deploymentServer, 'ip'),
];
foreach ($candidates as $candidate) {
$normalizedHost = $this->normalizeRemoteHost((string) $candidate);
if (! is_null($normalizedHost)) {
return $normalizedHost;
}
}
return null;
}
private function parseServiceCompose(Service $service): array
{
if (blank($service->docker_compose_raw)) {
return [];
}
try {
$parsedCompose = Yaml::parse($service->docker_compose_raw);
return is_array($parsedCompose) ? $parsedCompose : [];
} catch (\Throwable) {
return [];
}
}
private function serviceEnvironmentMap(Service $service): array
{
if ($service->relationLoaded('environment_variables')) {
return $service->environment_variables
->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value])
->all();
}
if (! $service->exists) {
return [];
}
return $service->environment_variables()
->get()
->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value])
->all();
}
private function parseApplicationCompose(Application $application): array
{
if ($application->build_pack !== 'dockercompose') {
return [];
}
$rawCompose = filled($application->docker_compose_raw)
? $application->docker_compose_raw
: $application->docker_compose;
if (blank($rawCompose)) {
return [];
}
try {
$parsedCompose = Yaml::parse($rawCompose);
return is_array($parsedCompose) ? $parsedCompose : [];
} catch (\Throwable) {
return [];
}
}
private function applicationEnvironmentMap(Application $application): array
{
if ($application->relationLoaded('environment_variables')) {
return $application->environment_variables
->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value])
->all();
}
if (! $application->exists) {
return [];
}
return $application->environment_variables()
->get()
->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value])
->all();
}
private function mergeComposeEnvironmentMap(array $serviceConfig, array $environmentMap): array
{
$resolvedEnvironmentMap = $environmentMap;
foreach ($this->composeEnvironmentDefinitions($serviceConfig) as $environmentKey => $rawValue) {
if (
array_key_exists($environmentKey, $environmentMap) &&
trim((string) $environmentMap[$environmentKey]) !== ''
) {
continue;
}
$resolvedValue = $this->resolveEnvironmentValue($rawValue, $resolvedEnvironmentMap);
if (! is_null($resolvedValue)) {
$resolvedEnvironmentMap[$environmentKey] = $resolvedValue;
}
}
return $resolvedEnvironmentMap;
}
private function composeEnvironmentDefinitions(array $serviceConfig): array
{
$environmentDefinitions = [];
$environment = data_get($serviceConfig, 'environment', []);
if (! is_array($environment)) {
return $environmentDefinitions;
}
foreach ($environment as $key => $value) {
if (is_int($key)) {
$environmentPair = explode('=', (string) $value, 2);
if (count($environmentPair) !== 2 || trim($environmentPair[0]) === '') {
continue;
}
$environmentDefinitions[trim($environmentPair[0])] = $environmentPair[1];
continue;
}
if (! is_string($key) || trim($key) === '') {
continue;
}
if (! is_scalar($value) && ! is_null($value)) {
continue;
}
$environmentDefinitions[trim($key)] = $value;
}
return $environmentDefinitions;
}
private function resolveEnvironmentValue(mixed $rawValue, array $environmentMap): ?string
{
if (is_null($rawValue)) {
return null;
}
if (is_bool($rawValue)) {
return $rawValue ? 'true' : 'false';
}
if (is_int($rawValue) || is_float($rawValue)) {
return (string) $rawValue;
}
$normalizedValue = trim((string) $rawValue);
if ($normalizedValue === '') {
return '';
}
if (
preg_match(
'/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}$/',
$normalizedValue,
$matches
)
) {
$environmentKey = $matches[1];
$defaultValue = trim((string) ($matches[3] ?? ''));
$resolvedEnvironmentValue = $environmentMap[$environmentKey] ?? null;
if (! is_null($resolvedEnvironmentValue) && trim((string) $resolvedEnvironmentValue) !== '') {
return trim((string) $resolvedEnvironmentValue);
}
return $defaultValue !== '' ? $defaultValue : null;
}
if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $normalizedValue, $matches)) {
$environmentKey = $matches[1];
$resolvedEnvironmentValue = $environmentMap[$environmentKey] ?? null;
if (! is_null($resolvedEnvironmentValue) && trim((string) $resolvedEnvironmentValue) !== '') {
return trim((string) $resolvedEnvironmentValue);
}
return null;
}
if (array_key_exists($normalizedValue, $environmentMap)) {
$resolvedEnvironmentValue = trim((string) $environmentMap[$normalizedValue]);
return $resolvedEnvironmentValue !== '' ? $resolvedEnvironmentValue : null;
}
return $normalizedValue;
}
private function splitPortDefinitionSegments(string $portDefinition): array
{
$segments = [];
$currentSegment = '';
$braceDepth = 0;
$length = strlen($portDefinition);
for ($index = 0; $index < $length; $index++) {
$character = $portDefinition[$index];
if ($character === '{') {
$braceDepth++;
} elseif ($character === '}' && $braceDepth > 0) {
$braceDepth--;
}
if ($character === ':' && $braceDepth === 0) {
$segments[] = $currentSegment;
$currentSegment = '';
continue;
}
$currentSegment .= $character;
}
$segments[] = $currentSegment;
return $segments;
}
private function resolvePortValue(mixed $rawPortValue, array $environmentMap): ?int
{
if (is_int($rawPortValue)) {
return $rawPortValue;
}
$normalizedPortValue = trim((string) $rawPortValue);
if ($normalizedPortValue === '') {
return null;
}
if (preg_match('/^\d+\s*-\s*\d+$/', $normalizedPortValue) === 1) {
return null;
}
if (preg_match('/^\d+$/', $normalizedPortValue)) {
return (int) $normalizedPortValue;
}
if (
preg_match(
'/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}$/',
$normalizedPortValue,
$matches
)
) {
$environmentKey = $matches[1];
$defaultPort = trim((string) ($matches[3] ?? ''));
$resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null;
if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) {
return (int) trim((string) $resolvedEnvironmentPort);
}
if ($defaultPort !== '' && is_numeric($defaultPort)) {
return (int) $defaultPort;
}
return null;
}
if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $normalizedPortValue, $matches)) {
$environmentKey = $matches[1];
$resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null;
if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) {
return (int) trim((string) $resolvedEnvironmentPort);
}
return null;
}
if (array_key_exists($normalizedPortValue, $environmentMap) && is_numeric(trim((string) $environmentMap[$normalizedPortValue]))) {
return (int) trim((string) $environmentMap[$normalizedPortValue]);
}
return null;
}
private function normalizeRemoteHost(string $rawHost): ?string
{
$host = trim($rawHost);
if ($host === '') {
return null;
}
if (str_starts_with($host, 'http://') || str_starts_with($host, 'https://')) {
$parsedHost = parse_url($host, PHP_URL_HOST);
$host = is_string($parsedHost) ? $parsedHost : '';
} elseif (str_contains($host, '/')) {
$parsedHost = parse_url('http://'.$host, PHP_URL_HOST);
$host = is_string($parsedHost) ? $parsedHost : '';
}
$host = trim($host, '[]');
if ($host === '') {
return null;
}
if (str_contains($host, ':') && ! filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$parsedHost = parse_url('http://'.$host, PHP_URL_HOST);
$host = is_string($parsedHost) ? $parsedHost : '';
}
if ($host === '') {
return null;
}
if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return '['.$host.']';
}
return $host;
}
}

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,15 @@ return [
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
'proxy' => [
'traefik' => [
'entrypoints' => [
'http' => env('TRAEFIK_HTTP_ENTRYPOINT', 'http'),
'https' => env('TRAEFIK_HTTPS_ENTRYPOINT', 'https'),
],
'cert_resolver' => env('TRAEFIK_CERT_RESOLVER', 'letsencrypt'),
],
],
],
'urls' => [

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('server_settings', 'is_master_domain_router_enabled')) {
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('is_master_domain_router_enabled')->default(false)->after('is_cloudflare_tunnel');
});
}
}
public function down(): void
{
if (Schema::hasColumn('server_settings', 'is_master_domain_router_enabled')) {
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('is_master_domain_router_enabled');
});
}
}
};

View file

@ -286,6 +286,20 @@
</div>
@endif
@if ($server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value)
<div class="w-96">
@if ($isMasterDomainRouterLocked)
<x-forms.checkbox disabled instantSave id="isMasterDomainRouterEnabled"
label="Enable master domain routing?"
helper="{{ $masterDomainRouterLockMessage }}" />
@else
<x-forms.checkbox canGate="update" :canResource="$server"
id="isMasterDomainRouterEnabled" label="Enable master domain routing?"
helper="Enable this on exactly one server when all domains point to this server and it should forward traffic to resources running on other servers (for example over VPN / WireGuard)."
:disabled="$isValidating" />
@endif
</div>
@endif
</div>
</div>
</form>

View file

@ -0,0 +1,68 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Server\Show;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('allows only one master domain router server per team', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$firstServer = Server::factory()->create(['team_id' => $team->id]);
$secondServer = Server::factory()->create(['team_id' => $team->id]);
$firstServer->settings->update(['is_master_domain_router_enabled' => true]);
expect($firstServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue()
->and($secondServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse();
$secondServer->settings->update(['is_master_domain_router_enabled' => true]);
expect($firstServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse()
->and($secondServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue();
});
it('does not disable master domain router setting on servers from other teams', function () {
$teamOneUser = User::factory()->create();
$teamTwoUser = User::factory()->create();
$teamOneServer = Server::factory()->create(['team_id' => $teamOneUser->teams()->first()->id]);
$teamTwoServer = Server::factory()->create(['team_id' => $teamTwoUser->teams()->first()->id]);
$teamOneServer->settings->update(['is_master_domain_router_enabled' => true]);
$teamTwoServer->settings->update(['is_master_domain_router_enabled' => true]);
expect($teamOneServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue()
->and($teamTwoServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue();
});
it('locks master domain router toggle when another server in the same team is already selected', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$this->actingAs($user);
refreshSession($team);
$masterServer = Server::factory()->create([
'team_id' => $team->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
]);
$otherServer = Server::factory()->create([
'team_id' => $team->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
]);
$masterServer->settings->update(['is_master_domain_router_enabled' => true]);
Livewire::test(Show::class, ['server_uuid' => $otherServer->uuid])
->assertSet('isMasterDomainRouterLocked', true)
->assertSet('isMasterDomainRouterEnabled', false)
->set('isMasterDomainRouterEnabled', true)
->assertSet('isMasterDomainRouterEnabled', false);
expect($otherServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse();
});

View file

@ -1,28 +1,8 @@
<?php
use App\Actions\Database\StartDatabaseProxy;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
});
test('database proxy is disabled on port already allocated error', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
'is_public' => true,
'public_port' => 5432,
]);
expect($database->is_public)->toBeTrue();
$action = new StartDatabaseProxy;
// Use reflection to test the private method directly

View file

@ -0,0 +1,789 @@
<?php
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\ServiceDatabase;
use App\Models\Server;
use App\Models\StandalonePostgresql;
it('runs standalone database proxy on the master domain router server for remote deployments', function () {
$edgeServer = \Mockery::mock(Server::class)->makePartial();
$edgeServer->id = 1;
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 2;
$deploymentServer->ip = '10.8.0.15';
$deploymentServer->proxy = ['type' => 'NONE'];
$database = new StandalonePostgresql;
$database->uuid = 'standalone-db-uuid';
$database->name = 'standalone-db';
$database->public_port = 15432;
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'standalone-network',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 123],
]);
$action = new class($edgeServer) extends StartDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($database);
expect($action->calls)->toHaveCount(3)
->and($action->calls[0]['server_id'])->toBe(2)
->and($action->calls[1]['server_id'])->toBe(1)
->and($action->calls[2]['server_id'])->toBe(1);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 15432;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.15:5432;');
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[2]['commands'][2], $composeMatches);
$dockerCompose = base64_decode($composeMatches[1] ?? '');
expect($dockerCompose)->not->toContain('external: true')
->and($dockerCompose)->not->toContain('standalone-network');
});
it('keeps configurable database proxy timeout when routing through the master domain router server', function () {
$edgeServer = \Mockery::mock(Server::class)->makePartial();
$edgeServer->id = 3;
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 4;
$deploymentServer->ip = '10.8.0.44';
$deploymentServer->proxy = ['type' => 'NONE'];
$database = new StandalonePostgresql;
$database->uuid = 'standalone-db-timeout-uuid';
$database->name = 'standalone-db-timeout';
$database->public_port = 15444;
$database->public_port_timeout = 7200;
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'standalone-timeout-network',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 124],
]);
$action = new class($edgeServer) extends StartDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($database);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('proxy_pass 10.8.0.44:5432;')
->and($nginxConf)->toContain('proxy_timeout 7200s;');
});
it('keeps standalone database proxy on deployment server when no master domain router server is configured', function () {
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 2;
$deploymentServer->ip = '10.8.0.25';
$deploymentServer->proxy = ['type' => 'NONE'];
$database = new StandalonePostgresql;
$database->uuid = 'standalone-db-local-uuid';
$database->name = 'standalone-db-local';
$database->public_port = 15433;
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'standalone-network-local',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 456],
]);
$action = new class extends StartDatabaseProxy
{
public array $calls = [];
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return null;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($database);
expect($action->calls)->toHaveCount(2)
->and($action->calls[0]['server_id'])->toBe(2)
->and($action->calls[1]['server_id'])->toBe(2);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[1]['commands'][1], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('proxy_pass standalone-db-local-uuid:5432;');
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[1]['commands'][2], $composeMatches);
$dockerCompose = base64_decode($composeMatches[1] ?? '');
expect($dockerCompose)->toContain('standalone-network-local')
->and($dockerCompose)->toContain('external: true');
});
it('runs service database proxy on master domain router server for remote deployments', function () {
$edgeServer = \Mockery::mock(Server::class)->makePartial();
$edgeServer->id = 11;
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 22;
$deploymentServer->ip = '10.8.0.35';
$deploymentServer->proxy = ['type' => 'NONE'];
$serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial();
$serviceDatabase->uuid = 'service-db-uuid';
$serviceDatabase->name = 'postgres';
$serviceDatabase->public_port = 15434;
$serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class);
$serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql');
$serviceDatabase->setRelation('service', (object) [
'uuid' => 'service-uuid',
'destination' => (object) ['server' => $deploymentServer],
'environment' => (object) ['project' => (object) ['team_id' => 789]],
]);
$action = new class($edgeServer) extends StartDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($serviceDatabase);
expect($action->calls)->toHaveCount(3)
->and($action->calls[0]['server_id'])->toBe(22)
->and($action->calls[1]['server_id'])->toBe(11)
->and($action->calls[2]['server_id'])->toBe(11);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 15434;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.35:5432;')
->and($nginxConf)->not->toContain('postgres-service-uuid');
});
it('stops database proxy containers on deployment and master domain router servers', function () {
$edgeServer = \Mockery::mock(Server::class)->makePartial();
$edgeServer->id = 101;
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 202;
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
$database->uuid = 'stopped-db-uuid';
$database->shouldReceive('save')->once()->andReturnTrue();
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 555],
]);
$action = new class($edgeServer) extends StopDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function dispatchDatabaseProxyStoppedEvent(): void
{
// No-op in isolated unit test environment.
}
};
$action->handle($database);
expect($action->calls)->toHaveCount(2)
->and($action->calls[0]['server_id'])->toBe(202)
->and($action->calls[1]['server_id'])->toBe(101)
->and($action->calls[0]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy')
->and($action->calls[1]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy');
});
it('falls back to deployment server when master domain router exists but remote host is missing', function () {
$edgeServer = \Mockery::mock(Server::class)->makePartial();
$edgeServer->id = 41;
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 42;
$deploymentServer->ip = '';
$deploymentServer->proxy = ['type' => 'NONE'];
$database = new StandalonePostgresql;
$database->uuid = 'standalone-db-fallback-uuid';
$database->name = 'standalone-db-fallback';
$database->public_port = 15440;
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'fallback-network',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 321],
]);
$action = new class($edgeServer) extends StartDatabaseProxy
{
public array $calls = [];
public array $warnings = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
protected function logWarning(string $message): void
{
$this->warnings[] = $message;
}
};
$action->handle($database);
expect($action->calls)->toHaveCount(2)
->and($action->calls[0]['server_id'])->toBe(42)
->and($action->calls[1]['server_id'])->toBe(42)
->and($action->warnings)->not->toBeEmpty()
->and($action->warnings[0])->toContain('falling back to deployment server');
});
it('keeps proxy on deployment server when master router server is the same server', function () {
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 51;
$deploymentServer->ip = '10.8.0.51';
$deploymentServer->proxy = ['type' => 'NONE'];
$database = new StandalonePostgresql;
$database->uuid = 'standalone-db-same-edge-uuid';
$database->name = 'standalone-db-same-edge';
$database->public_port = 15451;
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'same-edge-network',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 654],
]);
$action = new class($deploymentServer) extends StartDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($database);
expect($action->calls)->toHaveCount(2)
->and($action->calls[0]['server_id'])->toBe(51)
->and($action->calls[1]['server_id'])->toBe(51);
});
it('supports service database deployment server fallback from service.server when destination is missing', function () {
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 61;
$deploymentServer->ip = '10.8.0.61';
$deploymentServer->proxy = ['type' => 'NONE'];
$serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial();
$serviceDatabase->uuid = 'service-db-fallback-server-uuid';
$serviceDatabase->name = 'postgres';
$serviceDatabase->public_port = 15461;
$serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class);
$serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql');
$serviceDatabase->setRelation('service', (object) [
'uuid' => 'service-fallback-uuid',
'server' => $deploymentServer,
'environment' => (object) ['project' => (object) ['team_id' => 111]],
]);
$action = new class extends StartDatabaseProxy
{
public array $calls = [];
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return null;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($serviceDatabase);
expect($action->calls)->toHaveCount(2)
->and($action->calls[0]['server_id'])->toBe(61)
->and($action->calls[1]['server_id'])->toBe(61);
});
it('uses the dev host configuration path only for the bind mount source', function () {
$action = new class extends StartDatabaseProxy
{
public function configurationDirectory(string $databaseUuid): string
{
return $this->resolveConfigurationDirectory($databaseUuid);
}
public function hostConfigurationDirectory(string $databaseUuid): string
{
return $this->resolveHostConfigurationDirectory($databaseUuid);
}
protected function isDevelopmentEnvironment(): bool
{
return true;
}
};
expect($action->configurationDirectory('dev-db-uuid'))
->toBe('/data/coolify/databases/dev-db-uuid/proxy')
->and($action->hostConfigurationDirectory('dev-db-uuid'))
->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/dev-db-uuid/proxy');
});
it('uses ssl internal redis port 6380 for remote database proxy upstream target', function () {
$edgeServer = \Mockery::mock(Server::class)->makePartial();
$edgeServer->id = 71;
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 72;
$deploymentServer->ip = '10.8.0.72';
$deploymentServer->proxy = ['type' => 'NONE'];
$database = \Mockery::mock(\App\Models\StandaloneRedis::class)->makePartial();
$database->uuid = 'redis-ssl-db-uuid';
$database->name = 'redis-ssl-db';
$database->public_port = 16379;
$database->enable_ssl = true;
$database->database_type = 'standalone-redis';
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'redis-network',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 222],
]);
$action = new class($edgeServer) extends StartDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($database);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 16379;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.72:6380;');
});
it('disables public database proxy on non-transient startup errors', function () {
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 81;
$deploymentServer->ip = '10.8.0.81';
$deploymentServer->proxy = ['type' => 'NONE'];
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
$database->uuid = 'standalone-db-error-uuid';
$database->name = 'standalone-db-error';
$database->public_port = 15481;
$database->shouldReceive('update')->once()->with(['is_public' => false])->andReturnTrue();
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'error-network',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 333],
]);
$action = new class extends StartDatabaseProxy
{
public array $calls = [];
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
if (str_contains(implode("\n", $commands), 'up -d')) {
throw new \RuntimeException('Bind for 0.0.0.0:15481 failed: port is already allocated');
}
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return null;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($database);
expect($action->calls)->toHaveCount(2)
->and($action->calls[1]['server_id'])->toBe(81);
});
it('does not try to stop database proxy when deployment server is missing', function () {
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
$database->uuid = 'stop-missing-server-db-uuid';
$database->setRelation('destination', (object) [
'server' => null,
]);
$action = new class extends StopDatabaseProxy
{
public array $calls = [];
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function dispatchDatabaseProxyStoppedEvent(): void
{
// No-op in isolated unit test environment.
}
};
$action->handle($database);
expect($action->calls)->toBe([]);
});
it('stops proxy only once when edge and deployment server are the same', function () {
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 91;
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
$database->uuid = 'stop-same-edge-db-uuid';
$database->shouldReceive('save')->once()->andReturnTrue();
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 444],
]);
$action = new class($deploymentServer) extends StopDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function dispatchDatabaseProxyStoppedEvent(): void
{
// No-op in isolated unit test environment.
}
};
$action->handle($database);
expect($action->calls)->toHaveCount(1)
->and($action->calls[0]['server_id'])->toBe(91)
->and($action->calls[0]['commands'][0])->toBe('docker rm -f stop-same-edge-db-uuid-proxy');
});
it('skips starting database proxy when deployment server is missing', function () {
$database = new StandalonePostgresql;
$database->uuid = 'missing-deployment-server-db-uuid';
$database->name = 'missing-deployment-server-db';
$database->public_port = 15500;
$database->setRelation('destination', (object) [
'server' => null,
'network' => 'missing-network',
]);
$action = new class extends StartDatabaseProxy
{
public array $warnings = [];
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
protected function logWarning(string $message): void
{
$this->warnings[] = $message;
}
};
$action->handle($database);
expect($action->warnings)->toHaveCount(1)
->and($action->warnings[0])->toContain('deployment server is missing');
});
it('normalizes remote host with scheme and path before building database upstream target', function () {
$edgeServer = \Mockery::mock(Server::class)->makePartial();
$edgeServer->id = 111;
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 112;
$deploymentServer->ip = '';
$deploymentServer->proxy = ['type' => 'NONE', 'tunnel_host' => 'https://10.8.0.112:8443/path'];
$database = new StandalonePostgresql;
$database->uuid = 'normalized-remote-host-db-uuid';
$database->name = 'normalized-remote-host-db';
$database->public_port = 15501;
$database->setRelation('destination', (object) [
'server' => $deploymentServer,
'network' => 'normalized-network',
]);
$database->setRelation('environment', (object) [
'project' => (object) ['team_id' => 987],
]);
$action = new class($edgeServer) extends StartDatabaseProxy
{
public array $calls = [];
public function __construct(private ?Server $edgeServer) {}
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
{
return $this->edgeServer;
}
protected function resolveConfigurationDirectory(string $databaseUuid): string
{
return "/tmp/database-proxy/{$databaseUuid}";
}
};
$action->handle($database);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('proxy_pass 10.8.0.112:5432;')
->and($nginxConf)->not->toContain('8443');
});

View file

@ -0,0 +1,327 @@
<?php
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Services\EdgeProxyRemotePortForwardService;
use Illuminate\Container\Container;
use Psr\Log\NullLogger;
use Symfony\Component\Yaml\Yaml;
$originalLogger = null;
beforeEach(function () use (&$originalLogger) {
$container = Container::getInstance();
$originalLogger = $container->bound('log') ? $container->make('log') : null;
$container->instance('log', new NullLogger);
});
afterEach(function () use (&$originalLogger) {
if (is_null($originalLogger)) {
return;
}
Container::getInstance()->instance('log', $originalLogger);
});
it('mirrors application published tcp ports onto the edge server for remote deployments', function () {
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 1;
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 2;
$deploymentServer->ip = '10.8.0.15';
$application = new Application;
$application->uuid = 'application-port-forward';
$application->build_pack = 'nixpacks';
$application->ports_mappings = '25565:25565';
$manager = new class extends EdgeProxyRemotePortForwardService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
expect($warnings)->toBe([])
->and($manager->calls)->toHaveCount(1)
->and($manager->calls[0]['server_id'])->toBe(1);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 25565;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.15:25565;');
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $manager->calls[0]['commands'][3], $composeMatches);
$dockerCompose = base64_decode($composeMatches[1] ?? '');
$parsedCompose = Yaml::parse($dockerCompose);
expect(data_get($parsedCompose, 'services.application-application-port-forward-edge-port-proxy.container_name'))
->toBe('application-application-port-forward-edge-port-proxy')
->and(data_get($parsedCompose, 'services.application-application-port-forward-edge-port-proxy.ports'))
->toBe(['25565:25565']);
});
it('mirrors application published udp ports onto the edge server for remote deployments', function () {
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 11;
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 12;
$deploymentServer->ip = '10.8.0.25';
$application = new Application;
$application->uuid = 'application-udp-port-forward';
$application->build_pack = 'nixpacks';
$application->ports_mappings = '19132:19132/udp';
$manager = new class extends EdgeProxyRemotePortForwardService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
expect($warnings)->toBe([])
->and($manager->calls)->toHaveCount(1);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 19132 udp;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.25:19132;');
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $manager->calls[0]['commands'][3], $composeMatches);
$dockerCompose = base64_decode($composeMatches[1] ?? '');
$parsedCompose = Yaml::parse($dockerCompose);
expect(data_get($parsedCompose, 'services.application-application-udp-port-forward-edge-port-proxy.ports'))
->toBe(['19132:19132/udp']);
});
it('mirrors published compose ports for services onto the edge server', function () {
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 21;
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 22;
$deploymentServer->ip = '10.8.0.35';
$service = new Service;
$service->uuid = 'service-port-forward';
$service->docker_compose_raw = <<<'YAML'
services:
mc:
ports:
- "25565:25565"
- "19132:19132/udp"
YAML;
$manager = new class extends EdgeProxyRemotePortForwardService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
expect($warnings)->toBe([])
->and($manager->calls)->toHaveCount(1)
->and($manager->calls[0]['server_id'])->toBe(21);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 25565;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.35:25565;')
->and($nginxConf)->toContain('listen 19132 udp;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.35:19132;');
});
it('mirrors compose application published ports resolved from compose environment defaults', function () {
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 23;
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 24;
$deploymentServer->ip = '10.8.0.55';
$application = new Application;
$application->uuid = 'application-compose-env-default-port';
$application->build_pack = 'dockercompose';
$application->docker_compose_raw = <<<'YAML'
services:
mc:
ports:
- "${PORT}:25565"
environment:
- PORT=${PORT:-25565}
YAML;
$manager = new class extends EdgeProxyRemotePortForwardService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
expect($warnings)->toBe([])
->and($manager->calls)->toHaveCount(1);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 25565;')
->and($nginxConf)->toContain('proxy_pass 10.8.0.55:25565;');
});
it('warns and removes stale application edge port proxy when remote host is missing', function () {
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 31;
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 32;
$deploymentServer->ip = '';
$application = new Application;
$application->uuid = 'application-missing-remote-host';
$application->build_pack = 'nixpacks';
$application->ports_mappings = '25565:25565';
$manager = new class extends EdgeProxyRemotePortForwardService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
expect($warnings)->toHaveCount(1)
->and($warnings[0])->toContain('remote host is missing')
->and($manager->calls)->toHaveCount(1)
->and($manager->calls[0]['commands'][0])->toContain('docker rm -f');
});
it('skips reserved edge ports while keeping other published application ports', function () {
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 41;
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 42;
$deploymentServer->ip = '10.8.0.45';
$application = new Application;
$application->uuid = 'application-reserved-ports';
$application->build_pack = 'nixpacks';
$application->ports_mappings = '80:80,25565:25565,443:443';
$manager = new class extends EdgeProxyRemotePortForwardService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
expect($warnings)->toHaveCount(2)
->and($warnings[0])->toContain('reserved on the edge server')
->and($warnings[1])->toContain('reserved on the edge server')
->and($manager->calls)->toHaveCount(1);
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches);
$nginxConf = base64_decode($nginxMatches[1] ?? '');
expect($nginxConf)->toContain('listen 25565;')
->and($nginxConf)->not->toContain('listen 80;')
->and($nginxConf)->not->toContain('listen 443;');
});
it('deletes application edge port proxy containers', function () {
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 51;
$application = new Application;
$application->uuid = 'application-delete-port-proxy';
$manager = new class extends EdgeProxyRemotePortForwardService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'server_id' => $server->id,
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$manager->deleteApplicationWithServer($application, $edgeProxyServer);
expect($manager->calls)->toHaveCount(1)
->and($manager->calls[0]['server_id'])->toBe(51)
->and($manager->calls[0]['commands'][0])->toContain('application-application-delete-port-proxy-edge-port-proxy');
});

File diff suppressed because it is too large Load diff