Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e1453f0
fix(proxy): route remote service domains via edge Traefik file provider
Iisyourdad Feb 28, 2026
c6e561a
Don't delete all routes if only one domain is bad, return warnings in…
Iisyourdad Feb 28, 2026
35a5a77
feature realiability and added coverage. Fail softly
Iisyourdad Feb 28, 2026
91ea235
edge cases and just safer code
Iisyourdad Feb 28, 2026
aaf0b94
djsisson comment about addressing overlapping of CIDR network overlaps.
Iisyourdad Mar 4, 2026
7fc8bc6
support master-domain routing for remote apps and database proxies
Iisyourdad Mar 5, 2026
ef727c1
soft fail
Iisyourdad Mar 5, 2026
8fc7bc7
warn when no master router and make edge traefik routing configurable…
Iisyourdad Mar 5, 2026
fe25bac
Merge branch 'next' into fix/remote-server-forwarding
Iisyourdad Mar 7, 2026
31b10e7
Preserve remote edge TLS routes when published ports are unresolved. …
Iisyourdad Mar 8, 2026
3e24a80
fixed applications, tested with minecraft server
Iisyourdad Mar 8, 2026
e1f940d
v4.0.0-beta.464 (#8624)
andrasbacsai Mar 9, 2026
d2de030
v4.0.0-beta.465 (#8853)
andrasbacsai Mar 10, 2026
1ed0158
merge conflict changes
Iisyourdad Mar 11, 2026
b25ba69
Merge remote-tracking branch 'upstream/v4.x' into fix/remote-server-f…
Iisyourdad Mar 11, 2026
3cd2b56
v4.0.0-beta.466 (#8893)
andrasbacsai Mar 11, 2026
ce07681
v4.0.0-beta.467 (#8911)
andrasbacsai Mar 11, 2026
89aecc2
v4.0.0-beta.468 (#8929)
andrasbacsai Mar 12, 2026
7dde4f1
feat(server): add server metadata collection and display
andrasbacsai Mar 11, 2026
85cfe32
Fixing merge conflict.
Iisyourdad Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 194 additions & 43 deletions app/Actions/Database/StartDatabaseProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,15 +25,15 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
{
$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;

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) {
Expand All @@ -50,10 +51,35 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
};
}

$configuration_dir = database_proxy_dir($database->uuid);
if (isDev()) {
$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);
$nginxconf = <<<EOF
user nginx;
worker_processes auto;
Expand All @@ -66,61 +92,68 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
stream {
server {
listen $database->public_port;
proxy_pass $containerName:$internalPort;
proxy_pass $upstreamTarget;
}
}
EOF;
$proxyServiceCompose = [
'image' => 'nginx:stable-alpine',
'container_name' => $proxyContainerName,
'restart' => RESTART_MODE,
'ports' => [
"$database->public_port:$database->public_port",
],
'volumes' => [
[
'type' => 'bind',
'source' => "$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',
],
];

$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' => "$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',
],
],
$proxyContainerName => $proxyServiceCompose,
],
'networks' => [
$network => [
];

if (filled($proxyNetwork)) {
$docker_compose['services'][$proxyContainerName]['networks'] = [$proxyNetwork];
$docker_compose['networks'] = [
$proxyNetwork => [
'external' => true,
'name' => $network,
'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]);
Expand All @@ -131,7 +164,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$team?->notify(
new \App\Notifications\Container\ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
$proxyServer,
)
);

Expand All @@ -144,6 +177,124 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
}
}

protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
{
return instant_remote_process($commands, $server, $throwError);
}

protected function resolveConfigurationDirectory(string $databaseUuid): string
{
$configurationDirectory = database_proxy_dir($databaseUuid);
if (isDev()) {
$configurationDirectory = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$databaseUuid.'/proxy';
}

return $configurationDirectory;
}

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 = [
Expand Down
60 changes: 57 additions & 3 deletions app/Actions/Database/StopDatabaseProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Events\DatabaseProxyStopped;
use App\Models\ServiceDatabase;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
Expand All @@ -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();

$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;
}
}
Loading