feat(v5): add application ingress routing

This commit is contained in:
Andras Bacsai committed 2026-06-20 15:08:45 +02:00
1 parent 86156b6f7a
commit ad0bcc8a4a
16 files changed
+763 -235

No files matched your search

@@ -2,6 +2,9 @@
namespace App\Actions\V5\Proxy;
use App\Models\V5\Application;
use App\Models\V5\ApplicationDomain;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@@ -10,11 +13,21 @@ class GenerateCaddyIngressConfiguration
use AsAction;
/**
* @return array{compose: string, caddyfile: string, commands: array<int, string>}
* @param Collection<int, Application>|null $applications
* @return array{compose: string, caddyfile: string, apps: array<int, array{name: string, caddyfile: string}>}
*/
public function handle(string $basePath = '/data/coolify/v5/ingress/caddy'): array
public function handle(?Collection $applications = null): array
{
$compose = Yaml::dump([
return [
'compose' => $this->compose(),
'caddyfile' => $this->rootCaddyfile(),
'apps' => $this->appCaddyfiles($applications ?? collect()),
];
}
private function compose(): string
{
return Yaml::dump([
'services' => [
'caddy' => [
'image' => 'docker.io/library/caddy:2-alpine',
@@ -27,31 +40,76 @@ class GenerateCaddyIngressConfiguration
],
'volumes' => [
'./Caddyfile:/etc/caddy/Caddyfile:ro',
'./apps:/etc/caddy/apps:ro',
'./data:/data',
'./config:/config',
],
],
],
], 8, 2);
}
$caddyfile = <<<'CADDY'
private function rootCaddyfile(): string
{
return <<<'CADDY'
:80 {
respond /coolify-health 200
respond 404
}
CADDY;
return [
'compose' => $compose,
'caddyfile' => $caddyfile,
'commands' => [
sprintf('if [ "$(id -u)" = "0" ]; then mkdir -p %1$s/data %1$s/config; else sudo mkdir -p %1$s/data %1$s/config; fi', $basePath),
sprintf("printf '%%s' '%s' | base64 -d | if [ \"\$(id -u)\" = \"0\" ]; then tee %s/docker-compose.yml > /dev/null; else sudo tee %s/docker-compose.yml > /dev/null; fi", base64_encode($compose), $basePath, $basePath),
sprintf("printf '%%s' '%s' | base64 -d | if [ \"\$(id -u)\" = \"0\" ]; then tee %s/Caddyfile > /dev/null; else sudo tee %s/Caddyfile > /dev/null; fi", base64_encode($caddyfile), $basePath, $basePath),
'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime pull docker.io/library/caddy:2-alpine',
'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime rm -f coolify-v5-caddy 2>/dev/null || true',
"if command -v podman >/dev/null 2>&1; then runtime=\"sudo podman\"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo \"Neither podman nor docker is installed\" >&2; exit 1; fi; \$runtime run -d --name coolify-v5-caddy --restart unless-stopped -p 80:80 -p 443:443 -p 443:443/udp -v {$basePath}/Caddyfile:/etc/caddy/Caddyfile:ro -v {$basePath}/data:/data -v {$basePath}/config:/config docker.io/library/caddy:2-alpine",
],
];
import apps/*.caddy
CADDY;
}
/**
* @param Collection<int, Application> $applications
* @return array<int, array{name: string, caddyfile: string}>
*/
private function appCaddyfiles(Collection $applications): array
{
return $applications
->each(fn (Application $application) => $application->loadMissing('domains'))
->map(fn (Application $application) => [
'name' => $this->appFileName($application),
'caddyfile' => $this->applicationCaddyfile($application),
])
->filter(fn (array $file) => $file['caddyfile'] !== '')
->sortBy('name')
->values()
->all();
}
private function applicationCaddyfile(Application $application): string
{
if (! $application->ingress_enabled || ! $application->internal_port) {
return '';
}
return $application->domains
->map(fn (ApplicationDomain $domain) => $this->applicationRoute($application, $domain))
->filter()
->sort()
->implode("\n\n");
}
private function applicationRoute(Application $application, ApplicationDomain $domain): ?string
{
if ($domain->domain === '') {
return null;
}
$namespace = $application->mesh_namespace ?: 'default';
$upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$application->internal_port}";
return implode("\n", [
"{$domain->domain} {",
" reverse_proxy {$upstream}",
'}',
]);
}
private function appFileName(Application $application): string
{
return 'app_'.$application->getKey();
}
}
+26 -56
View File
@@ -2,84 +2,54 @@
namespace App\Actions\V5\Proxy;
use App\Models\PrivateKey;
use App\Models\V5\Application;
use App\Models\V5\Server;
use Illuminate\Support\Facades\Process;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
class StartCaddyIngress
{
use AsAction;
public function __construct(private readonly FluxClient $fluxClient) {}
public function handle(Server $server): string
{
$server->loadMissing('privateKey');
if (! $server->isIngress()) {
return 'Server is not an ingress server.';
}
if (! $server->privateKey instanceof PrivateKey) {
return 'No private key is attached to this server.';
$hostId = $server->wireguard_management_ip ?: $server->node_address;
if (! is_string($hostId) || $hostId === '') {
return 'Server is missing its Flux host id.';
}
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
try {
$commands = GenerateCaddyIngressConfiguration::run()['commands'];
$result = Process::timeout(180)->run([
'ssh',
'-o',
'BatchMode=yes',
'-o',
'LogLevel=ERROR',
'-o',
'StrictHostKeyChecking=no',
'-o',
'UserKnownHostsFile=/dev/null',
'-o',
'ConnectTimeout=10',
'-o',
'IdentitiesOnly=yes',
'-i',
$keyLocation,
'-p',
(string) $server->ssh_port,
"{$server->ssh_user}@{$server->host}",
implode("\n", $commands),
]);
$output = trim($result->output()."\n".$result->errorOutput());
if ($result->failed()) {
$server->update(['caddy_ingress_status' => 'failed']);
throw new \RuntimeException('Failed to start Caddy ingress: '.($output !== '' ? $output : 'No output returned.'));
}
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
$output = $this->fluxClient->applyCaddyIngress($hostId, $configuration['caddyfile'], $configuration['apps']);
if ($server->exists) {
$server->update(['caddy_ingress_status' => 'running']);
return $output !== '' ? $output : 'Caddy ingress started.';
} finally {
@unlink($keyLocation);
}
return $output;
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
/**
* @return Collection<int, Application>
*/
private function applications(Server $server): Collection
{
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
if (! $server->exists) {
return collect();
}
$keyLocation = tempnam($keyDirectory, 'v5_caddy_key_');
if ($keyLocation === false) {
throw new \RuntimeException('Could not create a temporary SSH key file.');
}
file_put_contents($keyLocation, $privateKey->private_key);
chmod($keyLocation, 0600);
return $keyLocation;
return Application::query()
->where('team_id', $server->team_id)
->where('server_id', $server->id)
->with('domains')
->orderBy('name')
->get();
}
}
+9 -56
View File
@@ -2,77 +2,30 @@
namespace App\Actions\V5\Proxy;
use App\Models\PrivateKey;
use App\Models\V5\Server;
use Illuminate\Support\Facades\Process;
use App\Services\Flux\FluxClient;
use Lorisleiva\Actions\Concerns\AsAction;
class StopCaddyIngress
{
use AsAction;
public function __construct(private readonly FluxClient $fluxClient) {}
public function handle(Server $server): string
{
$server->loadMissing('privateKey');
$hostId = $server->wireguard_management_ip ?: $server->node_address;
if (! $server->privateKey instanceof PrivateKey) {
return 'No private key is attached to this server.';
if (! is_string($hostId) || $hostId === '') {
return 'Server is missing its Flux host id.';
}
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
try {
$result = Process::timeout(60)->run([
'ssh',
'-o',
'BatchMode=yes',
'-o',
'LogLevel=ERROR',
'-o',
'StrictHostKeyChecking=no',
'-o',
'UserKnownHostsFile=/dev/null',
'-o',
'ConnectTimeout=10',
'-o',
'IdentitiesOnly=yes',
'-i',
$keyLocation,
'-p',
(string) $server->ssh_port,
"{$server->ssh_user}@{$server->host}",
'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime rm -f coolify-v5-caddy 2>/dev/null || true',
]);
$output = trim($result->output()."\n".$result->errorOutput());
if ($result->failed()) {
throw new \RuntimeException('Failed to stop Caddy ingress: '.($output !== '' ? $output : 'No output returned.'));
}
$output = $this->fluxClient->stopCaddyIngress($hostId);
if ($server->exists) {
$server->update(['caddy_ingress_status' => 'exited']);
return $output !== '' ? $output : 'Caddy ingress stopped.';
} finally {
@unlink($keyLocation);
}
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
{
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
}
$keyLocation = tempnam($keyDirectory, 'v5_caddy_key_');
if ($keyLocation === false) {
throw new \RuntimeException('Could not create a temporary SSH key file.');
}
file_put_contents($keyLocation, $privateKey->private_key);
chmod($keyLocation, 0600);
return $keyLocation;
return $output;
}
}