From d723a52d9cfc0c5a009b235d9bc30a7333ff8a59 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:24:15 +0200 Subject: [PATCH] fix(hetzner): secure token-backed option fetches Authorize stored Hetzner cloud provider tokens before fetching firewalls or networks, hide provider error details, and keep server creation intact if backup activation fails. Collapse advanced Hetzner server options by default while preserving visibility when advanced values are selected. --- .../Controllers/Api/HetznerController.php | 18 ++- app/Livewire/Server/New/ByHetzner.php | 91 +++++++++++- templates/service-templates-latest.json | 60 ++++---- templates/service-templates.json | 60 ++++---- tests/Feature/Api/HetznerApiTest.php | 137 ++++++++++++++++++ .../Server/HetznerServerCreationTest.php | 49 +++++-- 6 files changed, 330 insertions(+), 85 deletions(-) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index c17a5641e..151f9854b 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -543,13 +543,14 @@ public function firewalls(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getFirewalls()); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch firewalls: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500); } } @@ -637,13 +638,14 @@ public function networks(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getNetworks()); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch networks: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500); } } @@ -914,10 +916,6 @@ public function createServer(Request $request) // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); - if ($request->enable_backups) { - $hetznerService->enableServerBackup((int) $hetznerServer['id']); - } - // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -946,6 +944,14 @@ public function createServer(Request $request) $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($request->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + // Validate server if requested if ($request->instant_validate) { ValidateServer::dispatch($server); diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 95d70fe41..0da4ef344 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -74,6 +74,10 @@ class ByHetzner extends Component public bool $enable_backups = false; + public bool $show_advanced_hetzner_options = false; + + public bool $show_cloud_init_script = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -127,6 +131,8 @@ public function resetSelection() $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; + $this->show_advanced_hetzner_options = false; + $this->show_cloud_init_script = false; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; $this->selectedHetznerNetworkIds = []; @@ -185,6 +191,8 @@ protected function rules(): array 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', 'enable_backups' => 'required|boolean', + 'show_advanced_hetzner_options' => 'boolean', + 'show_cloud_init_script' => 'boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', @@ -444,6 +452,67 @@ public function getSelectedServerBackupSurchargeProperty(): ?string return '€'.number_format($price * 0.2, 2); } + public function getShouldShowAdvancedHetznerOptionsProperty(): bool + { + return $this->show_advanced_hetzner_options + || $this->selectedHetznerSshKeyIds !== [] + || $this->selectedHetznerFirewallIds !== [] + || $this->selectedHetznerNetworkIds !== [] + || $this->enable_backups + || ! $this->enable_ipv4 + || ! $this->enable_ipv6 + || $this->show_cloud_init_script + || filled($this->cloud_init_script) + || $this->save_cloud_init_script + || filled($this->cloud_init_script_name) + || filled($this->selected_cloud_init_script_id); + } + + public function getAdvancedHetznerOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedHetznerSshKeyIds) > 0) { + $summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds)); + } + + if (count($this->selectedHetznerFirewallIds) > 0) { + $summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds)); + } + + if (count($this->selectedHetznerNetworkIds) > 0) { + $summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds)); + } + + if ($this->enable_backups) { + $summary[] = 'Backups on'; + } + + if (! $this->enable_ipv4 || ! $this->enable_ipv6) { + $summary[] = collect([ + $this->enable_ipv4 ? 'IPv4' : null, + $this->enable_ipv6 ? 'IPv6' : null, + ])->filter()->join(' + ') ?: 'No public IP'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function toggleAdvancedHetznerOptions(): void + { + $this->show_advanced_hetzner_options = ! $this->show_advanced_hetzner_options; + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + $this->show_advanced_hetzner_options = true; + } + public function updatedSelectedLocation($value) { // Reset server type and image when location changes @@ -475,6 +544,15 @@ public function updatedSelectedCloudInitScriptId($value) $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); $this->cloud_init_script = $script->script; $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + $this->show_advanced_hetzner_options = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; } } @@ -484,6 +562,7 @@ public function clearCloudInitScript() $this->cloud_init_script = ''; $this->cloud_init_script_name = ''; $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; } private function createHetznerServer(HetznerService $hetznerService): array @@ -592,10 +671,6 @@ public function submit() // Create server on Hetzner $hetznerServer = $this->createHetznerServer($hetznerService); - if ($this->enable_backups) { - $hetznerService->enableServerBackup((int) $hetznerServer['id']); - } - // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -624,6 +699,14 @@ public function submit() $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($this->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + if ($this->from_onboarding) { // Complete the boarding when server is successfully created via Hetzner currentTeam()->update([ diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 08e9b5bd4..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:01:53+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 17436ccfe..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:01:53+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index 27c00d92b..46311e5b5 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -278,6 +278,27 @@ $response->assertJsonCount(2); $response->assertJsonFragment(['name' => 'web-firewall']); }); + + test('member read token cannot use a stored cloud provider token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake([ + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'firewalls' => [['id' => 38, 'name' => 'web-firewall']], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); + }); }); describe('GET /api/v1/hetzner/networks', function () { @@ -301,6 +322,27 @@ $response->assertJsonCount(2); $response->assertJsonFragment(['name' => 'private-eu']); }); + + test('member read token cannot use a stored cloud provider token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake([ + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'networks' => [['id' => 456, 'name' => 'private-eu']], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); + }); }); describe('POST /api/v1/servers/hetzner', function () { @@ -411,6 +453,67 @@ && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup'); }); + test('registers server when backup enablement fails after Hetzner creation', function () { + Http::fake(function (HttpRequest $request) { + if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) { + return Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') { + return Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') { + return Http::response([ + 'server' => [ + 'id' => 456, + 'name' => 'test-server', + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') { + return Http::response([ + 'error' => ['message' => 'backup unavailable'], + ], 500); + } + + return Http::response([], 404); + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => true, + 'enable_ipv6' => true, + 'enable_backups' => true, + ]); + + $response->assertCreated(); + $response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']); + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'team_id' => $this->team->id, + 'hetzner_server_id' => 456, + ]); + }); + test('generates server name if not provided', function () { Http::fake([ 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ @@ -711,4 +814,38 @@ $response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']); expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); }); + + test('firewalls endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('networks endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); }); diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php index 0e4a228db..e5efe3862 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -144,15 +144,6 @@ ->and(count($sshKeys))->toBe(3); }); -it('validates network array merging removes duplicate Hetzner network ids', function () { - $selectedHetznerNetworkIds = [456, 789, 456]; - - $networkIds = array_values(array_unique($selectedHetznerNetworkIds)); - - expect($networkIds)->toBe([456, 789]) - ->and(count($networkIds))->toBe(2); -}); - describe('Boarding Flow Integration', function () { uses(RefreshDatabase::class); @@ -171,7 +162,7 @@ test('completes boarding when server is created from onboarding', function () { // Verify boarding is initially enabled - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component with from_onboarding flag $component = Livewire::test(ByHetzner::class) @@ -188,23 +179,51 @@ test('boarding flag remains unchanged when not from onboarding', function () { // Verify boarding is initially enabled - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component without from_onboarding flag (default false) Livewire::test(ByHetzner::class) ->set('from_onboarding', false); // Boarding should still be enabled since it wasn't created from onboarding - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); }); - test('shows the backups option with the pricing note in the create dialog', function () { + test('keeps advanced Hetzner options collapsed by default in the create dialog', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) - ->assertSet('enable_backups', false) + ->assertSet('show_advanced_hetzner_options', false) + ->assertSee('Advanced Hetzner options') + ->assertDontSee('Extra SSH Keys') + ->assertDontSee('Firewalls') + ->assertDontSee('Private Networks') + ->assertDontSee('Enable Hetzner Backups') + ->assertDontSee('Cloud-Init Script'); + }); + + test('shows advanced Hetzner options when expanded', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->set('show_advanced_hetzner_options', true) + ->assertSee('Extra SSH Keys') + ->assertSee('Firewalls') + ->assertSee('Private Networks') ->assertSee('Enable Hetzner Backups') + ->assertSee('Add cloud-init script') ->assertSee('additional 20% of the server monthly fee'); }); + + test('shows the cloud init script name only when saving the script', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->set('show_advanced_hetzner_options', true) + ->set('show_cloud_init_script', true) + ->assertSee('Cloud-Init Script') + ->assertSee('Save this script for later use') + ->assertDontSee('Script name...') + ->set('save_cloud_init_script', true) + ->assertSee('Script name...'); + }); }); describe('Hetzner data loading', function () { @@ -239,7 +258,7 @@ ], 200), 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ - ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], + ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200),