Skip to content
Open
498 changes: 498 additions & 0 deletions app/Livewire/Server/Proxy/Gateway.php

Large diffs are not rendered by default.

229 changes: 229 additions & 0 deletions app/Livewire/Server/Proxy/GatewayRouteForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?php

namespace App\Livewire\Server\Proxy;

use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component;

class GatewayRouteForm extends Component
{
use AuthorizesRequests;

public $server_id;

public ?string $routerName = null;

public ?string $sourceFile = null;

public Server $server;

public string $name = '';

public string $domain = '';

public string $target_url = '';

public string $path_prefix = '/';

public string $entrypoints_input = 'https';

public string $tls_enabled = '1';

public string $tls_cert_resolver = 'letsencrypt';

public string $https_redirect = '0';

public string $pass_host_header = '1';

public string $strip_prefix = '0';

public function mount()
{
$this->server = Server::ownedByCurrentTeam()->whereId($this->server_id)->firstOrFail();

if ($this->routerName) {
$existing = Gateway::findRoute($this->server, $this->routerName);

if ($existing) {
$this->sourceFile = $existing['source_file'];
$this->name = $existing['name'];
$this->domain = $existing['domain'];
$this->target_url = $existing['target_url'];
$this->path_prefix = $existing['path_prefix'];
$this->entrypoints_input = implode(',', $existing['entrypoints']);
$this->tls_enabled = $existing['tls_enabled'] ? '1' : '0';
$this->tls_cert_resolver = $existing['tls_cert_resolver'];
$this->https_redirect = $existing['https_redirect'] ? '1' : '0';
$this->pass_host_header = $existing['pass_host_header'] ? '1' : '0';
$this->strip_prefix = $existing['strip_prefix'] ? '1' : '0';
}
}
}

public function save()
{
try {
$this->authorize('update', $this->server);

$this->validate([
'name' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9 _\-]+$/'],
'domain' => ['required', 'string', 'max:255', 'regex:'.Gateway::DOMAIN_REGEX],
'target_url' => ['required', 'url:http,https', 'max:500'],
'path_prefix' => ['required', 'string', 'max:255', 'regex:#^/[A-Za-z0-9._\-/]*$#'],
'entrypoints_input' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9_,\s\-]*$/'],
'tls_cert_resolver' => ['nullable', 'string', 'max:100', 'regex:/^[A-Za-z0-9_\-]*$/'],
]);

$entrypoints = array_values(array_filter(array_map('trim', explode(',', $this->entrypoints_input))));
if (empty($entrypoints)) {
throw ValidationException::withMessages([
'entrypoints_input' => 'At least one entrypoint is required (e.g. https).',
]);
}

$slug = str($this->name)->slug()->toString();
if ($slug === '') {
throw ValidationException::withMessages([
'name' => 'Name must contain at least one letter or number.',
]);
}
$newRouterName = "gateway-{$slug}";

if ($newRouterName !== $this->routerName) {
if (Gateway::findRoute($this->server, $newRouterName) !== null) {
throw ValidationException::withMessages([
'name' => "A route named '{$this->name}' already exists on this server.",
]);
}

if (! $this->sourceFile) {
$newPath = Gateway::routeFilePath($this->server, $newRouterName);
if (Gateway::fileExistsAt($this->server, $newPath)) {
throw ValidationException::withMessages([
'name' => "A file named '{$newRouterName}.yaml' already exists on this server.",
]);
}
}
}

$delta = $this->buildRouteConfig($newRouterName, $entrypoints);

if ($this->sourceFile) {
$config = Gateway::readRouteFile($this->server, $this->sourceFile);
if ($this->routerName) {
$config = Gateway::stripRouter($config, $this->routerName);
}
$config = $this->mergeDelta($config, $delta);
Gateway::writeFile($this->server, $this->sourceFile, $config);
} else {
$file = Gateway::routeFilePath($this->server, $newRouterName);
Gateway::writeFile($this->server, $file, $delta);
}

$this->dispatch('gatewayRoutesSaved');
$this->dispatch('success', 'Gateway route saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}

private function buildRouteConfig(string $routerName, array $entrypoints): array
{
$serviceName = $routerName;

$tlsEnabled = $this->tls_enabled === '1';
$httpsRedirect = $this->https_redirect === '1';
$passHostHeader = $this->pass_host_header === '1';
$stripPrefix = $this->strip_prefix === '1';

if (str_starts_with($this->domain, '*.')) {
$base = substr($this->domain, 2);
$escapedBase = str_replace('.', '\\.', $base);
$rule = "HostRegexp(`^[a-zA-Z0-9-]+\\.{$escapedBase}$`)";
} else {
$rule = "Host(`{$this->domain}`)";
}
if ($this->path_prefix && $this->path_prefix !== '/') {
$rule .= " && PathPrefix(`{$this->path_prefix}`)";
}

$routers = [];
$services = [];
$middlewares = [];

$routerMiddlewares = [];

if ($stripPrefix && $this->path_prefix && $this->path_prefix !== '/') {
$middlewares["{$routerName}-stripprefix"] = [
'stripPrefix' => ['prefixes' => [$this->path_prefix]],
];
$routerMiddlewares[] = "{$routerName}-stripprefix";
}

$httpsRouter = [
'rule' => $rule,
'entryPoints' => $entrypoints,
'service' => $serviceName,
];
if (! empty($routerMiddlewares)) {
$httpsRouter['middlewares'] = $routerMiddlewares;
}
if ($tlsEnabled) {
$httpsRouter['tls'] = $this->tls_cert_resolver !== ''
? ['certResolver' => $this->tls_cert_resolver]
: [];
}
$routers[$routerName] = $httpsRouter;

if ($httpsRedirect) {
$middlewares["{$routerName}-redirect"] = [
'redirectScheme' => ['scheme' => 'https'],
];
$routers["{$routerName}-http"] = [
'rule' => $rule,
'entryPoints' => ['http'],
'service' => $serviceName,
'middlewares' => ["{$routerName}-redirect"],
];
}

$services[$serviceName] = [
'loadBalancer' => [
'passHostHeader' => $passHostHeader,
'servers' => [['url' => $this->target_url]],
],
];

$config = ['http' => [
'routers' => $routers,
'services' => $services,
]];
if (! empty($middlewares)) {
$config['http']['middlewares'] = $middlewares;
}

return $config;
}

private function mergeDelta(array $config, array $delta): array
{
$config['http'] = $config['http'] ?? [];

foreach (['routers', 'services', 'middlewares'] as $section) {
$existing = $config['http'][$section] ?? [];
$incoming = $delta['http'][$section] ?? [];
if (! empty($incoming)) {
$config['http'][$section] = array_merge($existing, $incoming);
}
}

return $config;
}

public function render()
{
return view('livewire.server.proxy.gateway-route-form');
}
}
28 changes: 28 additions & 0 deletions app/Livewire/Server/Proxy/GatewayShow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Livewire\Server\Proxy;

use App\Models\Server;
use Livewire\Component;

class GatewayShow extends Component
{
public ?Server $server = null;

public $parameters = [];

public function mount()
{
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}

public function render()
{
return view('livewire.server.proxy.gateway-show');
}
}
6 changes: 6 additions & 0 deletions resources/views/components/server/sidebar-proxy.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
href="{{ route('server.proxy.dynamic-confs', $parameters) }}">
<span class="menu-item-label">Dynamic Configurations</span>
</a>
@if ($server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value)
<a class="{{ request()->routeIs('server.proxy.gateway') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.proxy.gateway', $parameters) }}">
<span class="menu-item-label">Gateway</span>
</a>
@endif
<a class="{{ request()->routeIs('server.proxy.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}"
href="{{ route('server.proxy.logs', $parameters) }}">
<span class="menu-item-label">Logs</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,23 @@
<div x-init="$wire.initLoadDynamicConfigurations" class="flex flex-col gap-4">
@if ($contents?->isNotEmpty())
@foreach ($contents as $fileName => $value)
@php
$realName = str_replace('|', '.', $fileName);
$isManagedGateway =
str_starts_with($realName, 'gateway-') &&
str_ends_with($realName, '.yaml') &&
str_contains((string) $value, \App\Livewire\Server\Proxy\Gateway::MANAGED_FILE_MARKER);
@endphp
<div class="flex flex-col gap-2 py-2">
@if (str_replace('|', '.', $fileName) === 'coolify.yaml' ||
str_replace('|', '.', $fileName) === 'Caddyfile' ||
str_replace('|', '.', $fileName) === 'coolify.caddy' ||
str_replace('|', '.', $fileName) === 'default_redirect_503.yaml' ||
str_replace('|', '.', $fileName) === 'default_redirect_503.caddy')
@if (
$realName === 'coolify.yaml' ||
$realName === 'Caddyfile' ||
$realName === 'coolify.caddy' ||
$realName === 'default_redirect_503.yaml' ||
$realName === 'default_redirect_503.caddy' ||
$isManagedGateway)
<div>
<h3 class="dark:text-white">File: {{ str_replace('|', '.', $fileName) }}</h3>
<h3 class="dark:text-white">File: {{ $realName }}</h3>
</div>
<x-forms.textarea disabled name="proxy_settings"
wire:model="contents.{{ $fileName }}" rows="5" />
Expand Down
84 changes: 84 additions & 0 deletions resources/views/livewire/server/proxy/gateway-route-form.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<form wire:submit.prevent="save" class="flex flex-col w-full gap-4"
x-data="{
name: @js($name),
target_url: @js($target_url),
domain: @js($domain),
entrypoints_input: @js($entrypoints_input),
get canSubmit() {
return this.name.trim() !== ''
&& this.target_url.trim() !== ''
&& this.domain.trim() !== ''
&& this.entrypoints_input.trim() !== '';
}
}">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.input canGate="update" :canResource="$server" id="name" :value="$name" label="Service Name" required
placeholder="my-backend-api"
helper="Used to derive the Traefik router and service names (e.g. gateway-my-backend-api)."
x-on:input="name = $event.target.value" />
<x-forms.input canGate="update" :canResource="$server" id="target_url" :value="$target_url" label="Target URL"
required placeholder="http://192.168.1.10:3000"
helper="Where Traefik forwards matching traffic. Include scheme and port, e.g. http://10.0.0.5:8080."
x-on:input="target_url = $event.target.value" />
</div>

<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.input canGate="update" :canResource="$server" id="domain" :value="$domain" label="Domain" required
placeholder="api.example.com"
helper="Hostname Traefik matches against. Use *.example.com for wildcard subdomains."
x-on:input="domain = $event.target.value" />
<x-forms.input canGate="update" :canResource="$server" id="path_prefix" :value="$path_prefix"
label="Path Prefix" placeholder="/"
helper="Optional PathPrefix rule. Use / to match all paths on the domain." />
</div>

<div x-show="domain.startsWith('*.')" x-cloak>
<x-callout type="warning" title="Wildcard domain">
Wildcard certs need a <strong>DNS-01</strong> challenge. Make sure it’s set up in the Traefik configuration.

</x-callout>
</div>

<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.input canGate="update" :canResource="$server" id="entrypoints_input" :value="$entrypoints_input"
label="Entrypoints (comma-separated)" required placeholder="websecure"
helper="Traefik entrypoints, e.g. http, https"
x-on:input="entrypoints_input = $event.target.value" />
<x-forms.input canGate="update" :canResource="$server" id="tls_cert_resolver" :value="$tls_cert_resolver"
label="TLS Cert Resolver" placeholder="letsencrypt"
helper="Name of a Traefik certResolver. Leave empty if a wildcard cert is pre-mounted in tls.certificates." />
</div>

<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.select id="tls_enabled" label="TLS Enabled"
helper="Terminate TLS on Traefik using the cert resolver above.">
<option value="1" @selected($tls_enabled === '1')>Yes</option>
<option value="0" @selected($tls_enabled === '0')>No</option>
</x-forms.select>
<x-forms.select id="https_redirect" label="HTTPS Redirect"
helper="Add a web entrypoint router that redirects HTTP to HTTPS.">
<option value="1" @selected($https_redirect === '1')>Yes</option>
<option value="0" @selected($https_redirect === '0')>No</option>
</x-forms.select>
</div>

<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.select id="pass_host_header" label="Pass Host Header"
helper="Forward the original Host header to the target (needed for downstream Traefik/vhosts).">
<option value="1" @selected($pass_host_header === '1')>Yes</option>
<option value="0" @selected($pass_host_header === '0')>No</option>
</x-forms.select>
<x-forms.select id="strip_prefix" label="Strip Path Prefix"
helper="Remove the path prefix before forwarding to the target.">
<option value="1" @selected($strip_prefix === '1')>Yes</option>
<option value="0" @selected($strip_prefix === '0')>No</option>
</x-forms.select>
</div>

<x-forms.button canGate="update" :canResource="$server" type="submit"
x-bind:disabled="!canSubmit"
x-bind:class="!canSubmit ? 'opacity-50 cursor-not-allowed' : ''"
x-on:click="if ($el.form.reportValidity() && canSubmit) { modalOpen = false }">
{{ $routerName ? 'Update Route' : 'Add Route' }}
</x-forms.button>
</form>
Loading