Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
78010cc
docs: update changelog
github-actions[bot] Feb 27, 2026
517c904
Remote server forwarding
Twest2 Feb 28, 2026
6e01311
docs: update changelog
github-actions[bot] Feb 28, 2026
45f8c0e
.
Twest2 Feb 28, 2026
89cd410
docs: update changelog
github-actions[bot] Feb 28, 2026
42a6580
.
Twest2 Feb 28, 2026
47ad42d
docs: update changelog
github-actions[bot] Feb 28, 2026
e1453f0
fix(proxy): route remote service domains via edge Traefik file provider
Twest2 Feb 28, 2026
c6e561a
Don't delete all routes if only one domain is bad, return warnings in…
Twest2 Feb 28, 2026
35a5a77
feature realiability and added coverage. Fail softly
Twest2 Feb 28, 2026
91ea235
edge cases and just safer code
Twest2 Feb 28, 2026
aaf0b94
djsisson comment about addressing overlapping of CIDR network overlaps.
Twest2 Mar 4, 2026
7fc8bc6
support master-domain routing for remote apps and database proxies
Twest2 Mar 5, 2026
ef727c1
soft fail
Twest2 Mar 5, 2026
8fc7bc7
warn when no master router and make edge traefik routing configurable…
Twest2 Mar 5, 2026
fe25bac
Merge branch 'next' into fix/remote-server-forwarding
Twest2 Mar 7, 2026
31b10e7
Preserve remote edge TLS routes when published ports are unresolved. …
Twest2 Mar 8, 2026
3e24a80
fixed applications, tested with minecraft server
Twest2 Mar 8, 2026
9f889b7
Merge branch 'coollabsio:v4.x' into v4.x
Twest2 Mar 11, 2026
53e4d4b
docs: update changelog
github-actions[bot] Mar 11, 2026
1ed0158
merge conflict changes
Twest2 Mar 11, 2026
b25ba69
Merge remote-tracking branch 'upstream/v4.x' into fix/remote-server-f…
Twest2 Mar 11, 2026
5387e1c
Merge branch 'coollabsio:v4.x' into v4.x
Twest2 Mar 13, 2026
c40bc56
docs: update changelog
github-actions[bot] Mar 13, 2026
7dde4f1
feat(server): add server metadata collection and display
andrasbacsai Mar 11, 2026
85cfe32
Fixing merge conflict.
Twest2 Mar 16, 2026
3fa554f
Merge branch 'coollabsio:v4.x' into v4.x
Twest2 Mar 20, 2026
6a0e183
docs: update changelog
github-actions[bot] Mar 20, 2026
a832c98
Merge branch 'v4.x' into remote-server-forwarding
Twest2 Mar 21, 2026
a390498
coderrabbitai comments
Twest2 Mar 21, 2026
7f6034a
Merge branch 'next' into fix/remote-server-forwarding
Twest2 Mar 26, 2026
a6da20b
Merge branch 'next' into fix/remote-server-forwarding
Twest2 Mar 30, 2026
44914df
Removing changelog from pr
Twest2 Mar 30, 2026
c8d37c7
Reset changelog to next
Twest2 Mar 30, 2026
1e01e70
test(database): fix proxy stopped event assertions in isolated unit t…
Twest2 Apr 4, 2026
cc99cc0
Fix edge proxy remote routing guardrails
Twest2 Apr 8, 2026
fcf75b6
Retry edge cleanup before final deletion
Twest2 Apr 9, 2026
1218673
Fix delete job logging fallback
Twest2 Apr 9, 2026
89d39bd
Add SSH failure coverage for edge cleanup
Twest2 Apr 9, 2026
1515049
Respect master router cert resolver
Twest2 Apr 13, 2026
f4285f6
Fix parser cert resolver ownership
Twest2 Apr 13, 2026
eed2d4d
Rebuild remote proxy files on startup
Twest2 Apr 13, 2026
4b4668f
Removing reconfiguring of yaml files
Twest2 Apr 14, 2026
f9abe2c
fix(application): initialize public cert resolver before label genera…
Apr 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');
Comment thread
Twest2 marked this conversation as resolved.
$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;
Comment thread
Twest2 marked this conversation as resolved.
}
}
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');
Comment thread
Twest2 marked this conversation as resolved.
$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();
}
Comment thread
Twest2 marked this conversation as resolved.
Outdated

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();
}
Comment thread
Twest2 marked this conversation as resolved.
Outdated

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