diff --git a/app/Livewire/Server/Proxy/Gateway.php b/app/Livewire/Server/Proxy/Gateway.php new file mode 100644 index 0000000000..16eac0d9fb --- /dev/null +++ b/app/Livewire/Server/Proxy/Gateway.php @@ -0,0 +1,498 @@ + 'loadRoutes']; + + public function mount() + { + $this->parameters = get_route_parameters(); + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first(); + if (is_null($this->server)) { + return redirect()->route('server.index'); + } + if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) { + return redirect()->route('server.proxy', ['server_uuid' => $this->server->uuid]); + } + $this->routes = collect(); + $this->brokenFiles = collect(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function initLoadRoutes() + { + $this->loadRoutes(); + } + + public function loadRoutes() + { + try { + $this->loading = true; + $this->checkDnsChallenge(); + $routes = []; + $broken = []; + foreach (self::listRouteFiles($this->server) as $file) { + try { + $config = self::readRouteFile($this->server, $file); + $routes = array_merge($routes, self::parseRoutes($config, $file)); + } catch (\Throwable $e) { + $broken[] = ['file' => $file, 'error' => $e->getMessage()]; + } + } + $byFile = collect($routes)->groupBy('source_file'); + $this->routes = collect($routes) + ->map(fn ($r) => $r + ['is_only_in_file' => $byFile[$r['source_file']]->count() === 1]) + ->sortBy('name') + ->values(); + $this->brokenFiles = collect($broken)->sortBy('file')->values(); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->loading = false; + } + } + + private function checkDnsChallenge(): void + { + $file = escapeshellarg($this->server->proxyPath().'/docker-compose.yml'); + $contents = instant_remote_process( + ["test -f {$file} && cat {$file} || echo ''"], + $this->server, + throwError: false, + ); + $this->dnsChallengeMissing = ! str_contains(strtolower($contents ?? ''), 'dnschallenge=true'); + } + + public function deleteRoute(string $routerName, string $password = '') + { + try { + $this->authorize('update', $this->server); + $route = self::findRoute($this->server, $routerName); + if (! $route) { + $this->dispatch('error', 'Gateway route not found.'); + + return; + } + self::removeRouterFromFile($this->server, $route['source_file'], $routerName); + $this->loadRoutes(); + $this->dispatch('success', 'Gateway route deleted.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function deleteBrokenFile(string $file, string $password = '') + { + try { + $this->authorize('update', $this->server); + if (dirname($file) !== self::gatewayDirPath($this->server) + || ! preg_match('/^gateway-[A-Za-z0-9._\-]+\.yaml$/', basename($file))) { + $this->dispatch('error', 'Invalid gateway file path.'); + + return; + } + self::deleteFile($this->server, $file); + $this->loadRoutes(); + $this->dispatch('success', 'Broken gateway file deleted.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public static function findRoute(Server $server, string $routerName): ?array + { + foreach (self::listRouteFiles($server) as $file) { + try { + $config = self::readRouteFile($server, $file); + foreach (self::parseRoutes($config, $file) as $route) { + if ($route['router_name'] === $routerName) { + return $route; + } + } + } catch (\Throwable) { + // Skip files we can't parse; surfaced as broken in loadRoutes. + } + } + + return null; + } + + public static function removeRouterFromFile(Server $server, string $file, string $routerName): void + { + $config = self::readRouteFile($server, $file); + $config = self::stripRouter($config, $routerName); + + $remaining = $config['http']['routers'] ?? []; + if (empty($remaining)) { + self::deleteFile($server, $file); + } else { + self::writeFile($server, $file, $config); + } + } + + public static function stripRouter(array $config, string $routerName): array + { + $routers = $config['http']['routers'] ?? []; + + $toRemove = [$routerName]; + if (isset($routers["{$routerName}-http"])) { + $toRemove[] = "{$routerName}-http"; + } + + foreach ($toRemove as $r) { + unset($config['http']['routers'][$r]); + } + unset($config['http']['services'][$routerName]); + foreach (['stripprefix', 'redirect'] as $suffix) { + unset($config['http']['middlewares']["{$routerName}-{$suffix}"]); + } + if (isset($config['http']['middlewares']) && empty($config['http']['middlewares'])) { + unset($config['http']['middlewares']); + } + + return $config; + } + + public static function gatewayDirPath(Server $server): string + { + return $server->proxyPath().'/dynamic'; + } + + public static function routeFilePath(Server $server, string $routerName): string + { + self::assertValidRouterName($routerName); + + return self::gatewayDirPath($server)."/{$routerName}.yaml"; + } + + private static function assertValidRouterName(string $routerName): void + { + if (! preg_match('/^gateway-[a-z0-9-]+$/', $routerName)) { + throw new \InvalidArgumentException('Invalid gateway route name.'); + } + } + + public static function listRouteFiles(Server $server): array + { + $dir = escapeshellarg(self::gatewayDirPath($server)); + $marker = escapeshellarg(self::MANAGED_FILE_MARKER); + $output = instant_remote_process( + ["grep -l -F {$marker} {$dir}/gateway-*.yaml 2>/dev/null || true"], + $server, + throwError: false, + ); + + return collect(preg_split('/\r?\n/', $output ?? '')) + ->map(fn ($s) => trim($s)) + ->filter() + ->values() + ->all(); + } + + public static function fileExistsAt(Server $server, string $path): bool + { + $escaped = escapeshellarg($path); + $output = instant_remote_process( + ["test -f {$escaped} && echo 1 || echo 0"], + $server, + throwError: false, + ); + + return trim($output ?? '') === '1'; + } + + public static function readRouteFile(Server $server, string $file): array + { + $escapedFile = escapeshellarg($file); + $contents = instant_remote_process( + ["test -f {$escapedFile} && cat {$escapedFile} || echo ''"], + $server, + throwError: false, + ); + if (empty(trim($contents ?? ''))) { + return []; + } + $parsed = Yaml::parse($contents); + + return is_array($parsed) ? $parsed : []; + } + + public static function writeFile(Server $server, string $file, array $config): void + { + $escapedFile = escapeshellarg($file); + $dir = escapeshellarg(dirname($file)); + $header = << /dev/null", + ], $server); + } + + public static function deleteFile(Server $server, string $file): void + { + $escapedFile = escapeshellarg($file); + instant_remote_process(["rm -f {$escapedFile}"], $server); + } + + public static function parseRoutes(array $config, ?string $sourceFile = null): array + { + $http = $config['http'] ?? []; + if (! is_array($http)) { + return []; + } + $routers = is_array($http['routers'] ?? null) ? $http['routers'] : []; + $services = is_array($http['services'] ?? null) ? $http['services'] : []; + $middlewares = is_array($http['middlewares'] ?? null) ? $http['middlewares'] : []; + $routes = []; + + foreach ($routers as $name => $router) { + // Skip the auto-generated HTTP redirect router; we surface it via the parent route. + if (str_ends_with($name, '-http') && isset($routers[substr($name, 0, -5)])) { + continue; + } + if (! is_array($router)) { + continue; + } + + $rule = $router['rule'] ?? ''; + $domain = ''; + $pathPrefix = '/'; + + if (preg_match('/Host\(`([^`]+)`\)/', $rule, $m)) { + $domain = $m[1]; + } elseif (preg_match('/HostRegexp\(`([^`]+)`\)/', $rule, $m)) { + $pattern = $m[1]; + $prefix = '^[a-zA-Z0-9-]+\\.'; + if (str_starts_with($pattern, $prefix) && str_ends_with($pattern, '$')) { + $base = substr($pattern, strlen($prefix), -1); + $domain = '*.'.str_replace('\\.', '.', $base); + } + } + if (preg_match('/PathPrefix\(`([^`]+)`\)/', $rule, $m)) { + $pathPrefix = $m[1]; + } + + $serviceName = $router['service'] ?? null; + $service = $serviceName ? ($services[$serviceName] ?? null) : null; + $targetUrlRaw = $service['loadBalancer']['servers'][0]['url'] ?? ''; + $targetUrl = is_string($targetUrlRaw) ? $targetUrlRaw : ''; + $isValidTargetUrl = $targetUrl !== '' + && filter_var($targetUrl, FILTER_VALIDATE_URL) !== false + && in_array(parse_url($targetUrl, PHP_URL_SCHEME), ['http', 'https'], true); + // Traefik defaults passHostHeader to true when omitted; preserve that behavior when parsing. + $lb = is_array($service['loadBalancer'] ?? null) ? $service['loadBalancer'] : null; + $passHostHeaderPresent = $lb !== null && array_key_exists('passHostHeader', $lb); + $passHostHeaderRaw = $passHostHeaderPresent ? $lb['passHostHeader'] : true; + $passHostHeader = $passHostHeaderRaw === true; + + $routerMiddlewares = is_array($router['middlewares'] ?? null) ? $router['middlewares'] : []; + $stripPrefix = false; + foreach ($routerMiddlewares as $mw) { + if (is_string($mw) && str_ends_with($mw, '-stripprefix')) { + $stripPrefix = true; + } + } + + $entrypointsPresent = array_key_exists('entryPoints', $router); + $entrypoints = is_array($router['entryPoints'] ?? null) ? $router['entryPoints'] : []; + + $isValidDomain = $domain !== '' + && strlen($domain) <= 255 + && preg_match(self::DOMAIN_REGEX, $domain) === 1; + + $missingFields = []; + if (! $isValidDomain) { + $missingFields[] = 'domain'; + } + if (! $isValidTargetUrl) { + $missingFields[] = 'target_url'; + } + // Traefik treats an omitted entryPoints as "all default entrypoints"; saving from the UI would + // narrow that to the form value, so flag it as missing and require the user to be explicit. + if (! $entrypointsPresent || empty($entrypoints)) { + $missingFields[] = 'entrypoints'; + } + if ($passHostHeaderPresent && ! is_bool($lb['passHostHeader'])) { + $missingFields[] = 'pass_host_header'; + } + + $routes[] = [ + 'router_name' => $name, + 'name' => preg_replace('/^gateway-/', '', $name), + 'source_file' => $sourceFile ?? '', + 'domain' => $domain, + 'path_prefix' => $pathPrefix, + 'target_url' => $targetUrl, + 'entrypoints' => $entrypoints, + 'tls_enabled' => isset($router['tls']), + 'tls_cert_resolver' => is_array($router['tls'] ?? null) ? ($router['tls']['certResolver'] ?? '') : '', + 'pass_host_header' => $passHostHeader, + 'https_redirect' => isset($routers["{$name}-http"]), + 'strip_prefix' => $stripPrefix, + 'has_extra_config' => self::detectExtraConfig($name, $router, $service, $middlewares, $config, $routers), + 'missing_fields' => $missingFields, + ]; + } + + return $routes; + } + + private static function detectExtraConfig(string $name, array $router, ?array $service, array $middlewares, array $config, array $routers = []): bool + { + $knownRouterKeys = ['rule', 'entryPoints', 'service', 'tls', 'middlewares']; + if (count(array_diff(array_keys($router), $knownRouterKeys)) > 0) { + return true; + } + + if (isset($router['tls']) && is_array($router['tls'])) { + $knownTlsKeys = ['certResolver']; + if (count(array_diff(array_keys($router['tls']), $knownTlsKeys)) > 0) { + return true; + } + } + + $routerMiddlewares = $router['middlewares'] ?? []; + if (! is_array($routerMiddlewares)) { + return true; + } + foreach ($routerMiddlewares as $mw) { + if (! is_string($mw)) { + return true; + } + if (! str_ends_with($mw, '-stripprefix') && ! str_ends_with($mw, '-redirect')) { + return true; + } + if (! self::middlewareBodyMatchesUiShape($mw, $middlewares[$mw] ?? null)) { + return true; + } + } + + // Inspect the auto-generated HTTP redirect router's middleware body if present. + $httpRedirectRouter = $routers["{$name}-http"] ?? null; + if (is_array($httpRedirectRouter)) { + $redirectMiddlewares = $httpRedirectRouter['middlewares'] ?? []; + if (is_array($redirectMiddlewares)) { + foreach ($redirectMiddlewares as $mw) { + if (is_string($mw) && str_ends_with($mw, '-redirect') + && ! self::middlewareBodyMatchesUiShape($mw, $middlewares[$mw] ?? null)) { + return true; + } + } + } + } + + if ($service !== null) { + $lb = $service['loadBalancer'] ?? null; + if ($lb === null) { + return true; + } + $knownLbKeys = ['passHostHeader', 'servers']; + if (count(array_diff(array_keys($lb), $knownLbKeys)) > 0) { + return true; + } + $servers = $lb['servers'] ?? []; + if (count($servers) > 1) { + return true; + } + if (isset($servers[0]) && is_array($servers[0]) && count(array_diff(array_keys($servers[0]), ['url'])) > 0) { + return true; + } + } + + if (count(array_diff(array_keys($config), ['http'])) > 0) { + return true; + } + if (isset($config['http']) && is_array($config['http'])) { + if (count(array_diff(array_keys($config['http']), ['routers', 'services', 'middlewares'])) > 0) { + return true; + } + } + + return false; + } + + private static function middlewareBodyMatchesUiShape(string $middlewareName, mixed $body): bool + { + if (! is_array($body)) { + return false; + } + + if (str_ends_with($middlewareName, '-stripprefix')) { + if (array_keys($body) !== ['stripPrefix']) { + return false; + } + $sp = $body['stripPrefix']; + if (! is_array($sp) || array_keys($sp) !== ['prefixes']) { + return false; + } + if (! is_array($sp['prefixes'])) { + return false; + } + foreach ($sp['prefixes'] as $prefix) { + if (! is_string($prefix)) { + return false; + } + } + + return true; + } + + if (str_ends_with($middlewareName, '-redirect')) { + if (array_keys($body) !== ['redirectScheme']) { + return false; + } + $rs = $body['redirectScheme']; + if (! is_array($rs) || array_keys($rs) !== ['scheme']) { + return false; + } + + return $rs['scheme'] === 'https'; + } + + return false; + } + + public function render() + { + return view('livewire.server.proxy.gateway'); + } +} diff --git a/app/Livewire/Server/Proxy/GatewayRouteForm.php b/app/Livewire/Server/Proxy/GatewayRouteForm.php new file mode 100644 index 0000000000..9b3e5f9a1f --- /dev/null +++ b/app/Livewire/Server/Proxy/GatewayRouteForm.php @@ -0,0 +1,229 @@ +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'); + } +} diff --git a/app/Livewire/Server/Proxy/GatewayShow.php b/app/Livewire/Server/Proxy/GatewayShow.php new file mode 100644 index 0000000000..8ce88c6fe4 --- /dev/null +++ b/app/Livewire/Server/Proxy/GatewayShow.php @@ -0,0 +1,28 @@ +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'); + } +} diff --git a/resources/views/components/server/sidebar-proxy.blade.php b/resources/views/components/server/sidebar-proxy.blade.php index 00bb3ee2a9..0a9589ca9e 100644 --- a/resources/views/components/server/sidebar-proxy.blade.php +++ b/resources/views/components/server/sidebar-proxy.blade.php @@ -8,6 +8,12 @@ href="{{ route('server.proxy.dynamic-confs', $parameters) }}"> Dynamic Configurations + @if ($server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value) + + Gateway + + @endif Logs diff --git a/resources/views/livewire/server/proxy/dynamic-configurations.blade.php b/resources/views/livewire/server/proxy/dynamic-configurations.blade.php index 60e5ea7109..c43ddc27ea 100644 --- a/resources/views/livewire/server/proxy/dynamic-configurations.blade.php +++ b/resources/views/livewire/server/proxy/dynamic-configurations.blade.php @@ -27,14 +27,23 @@
@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
- @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)
-

File: {{ str_replace('|', '.', $fileName) }}

+

File: {{ $realName }}

diff --git a/resources/views/livewire/server/proxy/gateway-route-form.blade.php b/resources/views/livewire/server/proxy/gateway-route-form.blade.php new file mode 100644 index 0000000000..c22940a188 --- /dev/null +++ b/resources/views/livewire/server/proxy/gateway-route-form.blade.php @@ -0,0 +1,84 @@ +
+
+ + +
+ +
+ + +
+ +
+ + Wildcard certs need a DNS-01 challenge. Make sure it’s set up in the Traefik configuration. + + +
+ +
+ + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + +
+ + + {{ $routerName ? 'Update Route' : 'Add Route' }} + +
diff --git a/resources/views/livewire/server/proxy/gateway-show.blade.php b/resources/views/livewire/server/proxy/gateway-show.blade.php new file mode 100644 index 0000000000..fe9e6bb09b --- /dev/null +++ b/resources/views/livewire/server/proxy/gateway-show.blade.php @@ -0,0 +1,16 @@ +
+ + Proxy Gateway | Coolify + + +
+ + @if ($server->isFunctional()) +
+ +
+ @else +
Server is not validated. Validate first.
+ @endif +
+
diff --git a/resources/views/livewire/server/proxy/gateway.blade.php b/resources/views/livewire/server/proxy/gateway.blade.php new file mode 100644 index 0000000000..fab215790c --- /dev/null +++ b/resources/views/livewire/server/proxy/gateway.blade.php @@ -0,0 +1,160 @@ +
+
+

Gateway

+
Route traffic through Traefik to external services.
+
+ + @if ($dnsChallengeMissing && $routes?->contains(fn ($r) => str_starts_with($r['domain'] ?? '', '*.'))) + + Wildcard TLS certificates configuration is missing on your Traefik configuration. Set it up by following +
this guide. + + @endif + +
+

Routes

+ + Reload + + + @can('update', $server) + + + + @endcan +
+ +
+ @if ($brokenFiles?->isNotEmpty()) + @foreach ($brokenFiles as $bf) +
+
+
+
+ + Broken file + +

{{ basename($bf['file']) }}

+
+
{{ $bf['error'] }}
+
+ @can('update', $server) + + @endcan +
+
+ @endforeach + @endif + @if ($routes?->isNotEmpty()) + @foreach ($routes as $route) +
+
+
+ + + +

{{ $route['name'] }}

+ @if (! empty($route['missing_fields'])) + + Missing fields + + @endif + @if ($route['has_extra_config']) + + Extra config + + @endif + {{ $route['domain'] }}{{ $route['path_prefix'] !== '/' ? $route['path_prefix'] : '' }} + + {{ $route['target_url'] }} +
+
+ @php + $readonlyClass = 'read-only:!bg-neutral-50 read-only:!text-black read-only:!border read-only:!border-neutral-300 dark:read-only:!bg-coolgray-200 dark:read-only:!text-white dark:read-only:!border dark:read-only:!border-coolgray-300'; + @endphp +
+
+ @if (! empty($route['missing_fields'])) + + The UI couldn't read these from {{ basename($route['source_file']) }}: + {{ implode(', ', $route['missing_fields']) }}. + The route may not work until you edit it and save valid values. + + @endif + @if ($route['has_extra_config']) + + {{ basename($route['source_file']) }} contains fields the UI doesn't manage + (e.g. custom middlewares, headers, multiple servers). Saving from this form will + overwrite this route's router/service/middleware entries; other routers in the + file are preserved. + + @endif +
+ + +
+
+ + + +
+
+ + + + +
+ @can('update', $server) +
+ + + + +
+ @endcan +
+
+
+ @endforeach + @else +
+ + No gateway routes configured yet. Click + Add to create one. + +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index fad3c5d297..49698d547c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -52,6 +52,7 @@ use App\Livewire\Server\LogDrains; use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow; use App\Livewire\Server\Proxy\DynamicConfigurations as ProxyDynamicConfigurations; +use App\Livewire\Server\Proxy\GatewayShow as ProxyGatewayShow; use App\Livewire\Server\Proxy\Logs as ProxyLogs; use App\Livewire\Server\Proxy\Show as ProxyShow; use App\Livewire\Server\Resources as ResourcesShow; @@ -291,6 +292,7 @@ Route::get('/danger', DeleteServer::class)->name('server.delete'); Route::get('/proxy', ProxyShow::class)->name('server.proxy'); Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); + Route::get('/proxy/gateway', ProxyGatewayShow::class)->name('server.proxy.gateway'); Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');