diff --git a/.cursor/rules/database-patterns.mdc b/.cursor/rules/database-patterns.mdc index a4f65f5fb..ec60a43b3 100644 --- a/.cursor/rules/database-patterns.mdc +++ b/.cursor/rules/database-patterns.mdc @@ -142,6 +142,29 @@ Schema::create('applications', function (Blueprint $table) { - **Soft deletes** for audit trails - **Activity logging** with Spatie package +### **CRITICAL: Mass Assignment Protection** +**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`. + +**Checklist for new columns:** +1. ✅ Create migration file +2. ✅ Run migration +3. ✅ **Add column to model's `$fillable` array** +4. ✅ Update any Livewire components that sync this property +5. ✅ Test that the column can be read and written + +**Example:** +```php +class Server extends BaseModel +{ + protected $fillable = [ + 'name', + 'ip', + 'port', + 'is_validating', // ← MUST add new columns here + ]; +} +``` + ### Relationship Patterns ```php // Typical relationship structure in Application model diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 09b1e9421..c6aa2dd90 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -28,6 +28,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: @@ -50,8 +57,8 @@ jobs: platforms: linux/amd64 push: true tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} aarch64: runs-on: [self-hosted, arm64] @@ -61,6 +68,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: @@ -83,8 +97,8 @@ jobs: platforms: linux/aarch64 push: true tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 merge-manifest: runs-on: ubuntu-latest @@ -95,6 +109,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - uses: docker/setup-buildx-action@v3 - name: Login to ${{ env.GITHUB_REGISTRY }} @@ -114,14 +135,14 @@ jobs: - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | docker buildx imagetools create \ - --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ - --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} run: | docker buildx imagetools create \ - --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ - --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - uses: sarisia/actions-status-discord@v1 if: always() diff --git a/CLAUDE.md b/CLAUDE.md index 6c594955c..34149d28a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,7 @@ ### Database Patterns - Use database transactions for critical operations - Leverage query scopes for reusable queries - Apply indexes for performance-critical queries +- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment ### Security Best Practices - **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum diff --git a/README.md b/README.md index 1c88f4c54..9be4130c2 100644 --- a/README.md +++ b/README.md @@ -53,40 +53,40 @@ # Donations ## Big Sponsors -* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy -* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management +* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! * [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform -* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform -* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions -* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers -* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions -* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers -* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase -* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers -* [Trieve](https://trieve.ai?ref=coolify.io) - AI-powered search and analytics -* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data -* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions -* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions -* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor -* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [WZ-IT](https://wz-it.com/?ref=coolify.io) - German agency for customised cloud solutions -* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner -* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform -* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions -* [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network -* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang -* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers -* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency -* [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions -* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services -* [MassiveGrid](https://massivegrid.com?ref=coolify.io) - Enterprise cloud hosting solutions -* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers -* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform +* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions +* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner +* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform +* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain +* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale +* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half +* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform +* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers +* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy +* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions +* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions * [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure +* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions +* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions +* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers +* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency +* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions +* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity +* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang +* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting +* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers +* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase +* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data +* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers +* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform +* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform +* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform ## Small Sponsors diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 38ad99d2e..0db73ba07 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -55,11 +55,11 @@ public function handle(StandaloneDragonfly $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 59bcd4123..2908eb9c1 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -56,11 +56,11 @@ public function handle(StandaloneKeydb $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 13dba4b43..11b424ff8 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -57,11 +57,11 @@ public function handle(StandaloneMariadb $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 870b5b7e5..0bf6ab253 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -61,11 +61,11 @@ public function handle(StandaloneMongodb $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 5d5611e07..bf81d01ab 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -57,11 +57,11 @@ public function handle(StandaloneMysql $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 38d46b3c1..6cdf32e27 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -62,11 +62,11 @@ public function handle(StandalonePostgresql $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 68a1f3fe3..c040ec391 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -56,11 +56,11 @@ public function handle(StandaloneRedis $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index ecfb13d0b..8671a5f27 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -19,6 +19,11 @@ public function handle(Server $server, bool $async = true, bool $force = false): if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) { return 'OK'; } + $server->proxy->set('status', 'starting'); + $server->save(); + $server->refresh(); + ProxyStatusChangedUI::dispatch($server->team_id); + $commands = collect([]); $proxy_path = $server->proxyPath(); $configuration = GetProxyConfiguration::run($server); @@ -64,14 +69,12 @@ public function handle(Server $server, bool $async = true, bool $force = false): ]); $commands = $commands->merge(connectProxyToNetworks($server)); } - $server->proxy->set('status', 'starting'); - $server->save(); - ProxyStatusChangedUI::dispatch($server->team_id); if ($async) { return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id); } else { instant_remote_process($commands, $server); + $server->proxy->set('type', $proxyType); $server->save(); ProxyStatusChanged::dispatch($server->id); diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 15c892e75..45ec68abc 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -2,16 +2,102 @@ namespace App\Actions\Server; +use App\Models\CloudProviderToken; use App\Models\Server; +use App\Models\Team; +use App\Notifications\Server\HetznerDeletionFailed; +use App\Services\HetznerService; use Lorisleiva\Actions\Concerns\AsAction; class DeleteServer { use AsAction; - public function handle(Server $server) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null) { - StopSentinel::run($server); - $server->forceDelete(); + $server = Server::withTrashed()->find($serverId); + + // Delete from Hetzner even if server is already gone from Coolify + if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) { + $this->deleteFromHetznerById( + $hetznerServerId ?? $server->hetzner_server_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + + ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); + + // If server is already deleted from Coolify, skip this part + if (! $server) { + return; // Server already force deleted from Coolify + } + + ray('force deleting server from Coolify', ['server_id' => $server->id]); + + try { + $server->forceDelete(); + } catch (\Throwable $e) { + ray('Failed to force delete server from Coolify', [ + 'error' => $e->getMessage(), + 'server_id' => $server->id, + ]); + logger()->error('Failed to force delete server from Coolify', [ + 'error' => $e->getMessage(), + 'server_id' => $server->id, + ]); + } + } + + private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + // Use the provided token, or fallback to first available team token + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::find($cloudProviderTokenId); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'hetzner') + ->first(); + } + + if (! $token) { + ray('No Hetzner token found for team, skipping Hetzner deletion', [ + 'team_id' => $teamId, + 'hetzner_server_id' => $hetznerServerId, + ]); + + return; + } + + $hetznerService = new HetznerService($token->token); + $hetznerService->deleteServer($hetznerServerId); + + ray('Deleted server from Hetzner', [ + 'hetzner_server_id' => $hetznerServerId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + ray('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $hetznerServerId, + 'team_id' => $teamId, + ]); + + // Log the error but don't prevent the server from being deleted from Coolify + logger()->error('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $hetznerServerId, + 'team_id' => $teamId, + ]); + + // Notify the team about the failure + $team = Team::find($teamId); + $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); + } } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 5410b1cbd..10589c8b9 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -4,7 +4,6 @@ use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneDocker; use Lorisleiva\Actions\Concerns\AsAction; @@ -20,7 +19,7 @@ public function handle(Server $server) throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation.'); } - if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) { + if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) { $serverCert = SslHelper::generateSslCertificate( commonName: 'Coolify CA Certificate', serverId: $server->id, diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index ce2d6d598..0b13462ef 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -13,6 +13,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Models\SslCertificate; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -58,6 +59,15 @@ private function cleanup_stucked_resources() } catch (\Throwable $e) { echo "Error in cleaning stucked resources: {$e->getMessage()}\n"; } + try { + $servers = Server::onlyTrashed()->get(); + foreach ($servers as $server) { + echo "Force deleting stuck server: {$server->name}\n"; + $server->forceDelete(); + } + } catch (\Throwable $e) { + echo "Error in cleaning stuck servers: {$e->getMessage()}\n"; + } try { $applicationsDeploymentQueue = ApplicationDeploymentQueue::get(); foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) { @@ -427,5 +437,18 @@ private function cleanup_stucked_resources() } catch (\Throwable $e) { echo "Error in ServiceDatabases: {$e->getMessage()}\n"; } + + try { + $orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) { + $query->select('id')->from('servers'); + })->get(); + + foreach ($orphanedCerts as $cert) { + echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n"; + $cert->delete(); + } + } catch (\Throwable $e) { + echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n"; + } } } diff --git a/app/Console/Commands/ClearGlobalSearchCache.php b/app/Console/Commands/ClearGlobalSearchCache.php new file mode 100644 index 000000000..a368b0bad --- /dev/null +++ b/app/Console/Commands/ClearGlobalSearchCache.php @@ -0,0 +1,83 @@ +option('all')) { + return $this->clearAllTeamsCache(); + } + + if ($teamId = $this->option('team')) { + return $this->clearTeamCache($teamId); + } + + // If no options provided, clear cache for current user's team + if (! auth()->check()) { + $this->error('No authenticated user found. Use --team=ID or --all option.'); + + return Command::FAILURE; + } + + $teamId = auth()->user()->currentTeam()->id; + + return $this->clearTeamCache($teamId); + } + + private function clearTeamCache(int $teamId): int + { + $team = Team::find($teamId); + + if (! $team) { + $this->error("Team with ID {$teamId} not found."); + + return Command::FAILURE; + } + + GlobalSearch::clearTeamCache($teamId); + $this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})"); + + return Command::SUCCESS; + } + + private function clearAllTeamsCache(): int + { + $teams = Team::all(); + + if ($teams->isEmpty()) { + $this->warn('No teams found.'); + + return Command::SUCCESS; + } + + $count = 0; + foreach ($teams as $team) { + GlobalSearch::clearTeamCache($team->id); + $count++; + } + + $this->info("✓ Cleared global search cache for {$count} team(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Events/ServerValidated.php b/app/Events/ServerValidated.php new file mode 100644 index 000000000..95a116ebe --- /dev/null +++ b/app/Events/ServerValidated.php @@ -0,0 +1,51 @@ +check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + $this->serverUuid = $serverUuid; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } + + public function broadcastAs(): string + { + return 'ServerValidated'; + } + + public function broadcastWith(): array + { + return [ + 'teamId' => $this->teamId, + 'serverUuid' => $this->serverUuid, + ]; + } +} diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index cbd20400a..811938386 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -746,7 +746,13 @@ public function delete_server(Request $request) return response()->json(['message' => 'Local server cannot be deleted.'], 400); } $server->delete(); - DeleteServer::dispatch($server); + DeleteServer::dispatch( + $server->id, + false, // Don't delete from Hetzner via API + $server->hetzner_server_id, + $server->cloud_provider_token_id, + $server->team_id + ); return response()->json(['message' => 'Server deleted.']); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 8ffaabde5..eafd25e07 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -484,6 +484,10 @@ private function deploy_simple_dockerfile() ); $this->generate_image_names(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); @@ -1432,9 +1436,9 @@ private function save_buildtime_environment_variables() 'hidden' => true, ], ); - } elseif ($this->build_pack === 'dockercompose') { - // For Docker Compose, create an empty .env file even if there are no build-time variables - // This ensures the file exists when referenced in docker-compose commands + } elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') { + // For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables + // This ensures the file exists when referenced in build commands $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); $this->execute_remote_command( @@ -3196,7 +3200,7 @@ private function add_build_env_variables_to_dockerfile() } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info'); + $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index cf598c75c..c0284e1ee 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -45,7 +45,7 @@ public function handle() $query->cursor()->each(function ($certificate) use ($regenerated) { try { - $caCert = SslCertificate::where('server_id', $certificate->server_id) + $caCert = $certificate->server->sslCertificates() ->where('is_ca_certificate', true) ->first(); diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php new file mode 100644 index 000000000..607fda3fe --- /dev/null +++ b/app/Jobs/SendWebhookJob.php @@ -0,0 +1,60 @@ +onQueue('high'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + if (isDev()) { + ray('Sending webhook notification', [ + 'url' => $this->webhookUrl, + 'payload' => $this->payload, + ]); + } + + $response = Http::post($this->webhookUrl, $this->payload); + + if (isDev()) { + ray('Webhook response', [ + 'status' => $response->status(), + 'body' => $response->body(), + 'successful' => $response->successful(), + ]); + } + } +} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 8b55434f6..9dbce4bfe 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -54,6 +54,11 @@ public function handle() return; } + // Check Hetzner server status if applicable + if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) { + $this->checkHetznerStatus(); + } + // Temporarily disable mux if requested if ($this->disableMux) { $this->disableSshMux(); @@ -86,6 +91,11 @@ public function handle() ]); } catch (\Throwable $e) { + + Log::error('ServerConnectionCheckJob failed', [ + 'error' => $e->getMessage(), + 'server_id' => $this->server->id, + ]); $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, @@ -95,6 +105,30 @@ public function handle() } } + private function checkHetznerStatus(): void + { + try { + $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $serverData = $hetznerService->getServer($this->server->hetzner_server_id); + $status = $serverData['status'] ?? null; + + } catch (\Throwable $e) { + Log::debug('ServerConnectionCheck: Hetzner status check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + } + if ($this->server->hetzner_server_status !== $status) { + $this->server->update(['hetzner_server_status' => $status]); + $this->server->hetzner_server_status = $status; + if ($status === 'off') { + ray('Server is powered off, marking as unreachable'); + throw new \Exception('Server is powered off'); + } + } + + } + private function checkConnection(): bool { try { diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php new file mode 100644 index 000000000..388791f10 --- /dev/null +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -0,0 +1,162 @@ +onQueue('high'); + } + + public function handle(): void + { + try { + // Mark validation as in progress + $this->server->update(['is_validating' => true]); + + Log::info('ValidateAndInstallServer: Starting validation', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + 'attempt' => $this->numberOfTries + 1, + ]); + + // Validate connection + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); + if (! $uptime) { + $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: Server not reachable', [ + 'server_id' => $this->server->id, + 'error' => $error, + ]); + + return; + } + + // Validate OS + $supportedOsType = $this->server->validateOS(); + if (! $supportedOsType) { + $errorMessage = 'Server OS type is not supported. Please install Docker manually before continuing: documentation.'; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: OS not supported', [ + 'server_id' => $this->server->id, + ]); + + return; + } + + // Check if Docker is installed + $dockerInstalled = $this->server->validateDockerEngine(); + $dockerComposeInstalled = $this->server->validateDockerCompose(); + + if (! $dockerInstalled || ! $dockerComposeInstalled) { + // Try to install Docker + if ($this->numberOfTries >= $this->maxTries) { + $errorMessage = 'Docker Engine could not be installed after '.$this->maxTries.' attempts. Please install Docker manually before continuing: documentation.'; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: Docker installation failed after max tries', [ + 'server_id' => $this->server->id, + 'attempts' => $this->numberOfTries, + ]); + + return; + } + + Log::info('ValidateAndInstallServer: Installing Docker', [ + 'server_id' => $this->server->id, + 'attempt' => $this->numberOfTries + 1, + ]); + + // Install Docker + $this->server->installDocker(); + + // Retry validation after installation + self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30)); + + return; + } + + // Validate Docker version + $dockerVersion = $this->server->validateDockerEngineVersion(); + if (! $dockerVersion) { + $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); + $errorMessage = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: documentation.'; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: Docker version not sufficient', [ + 'server_id' => $this->server->id, + ]); + + return; + } + + // Validation successful! + Log::info('ValidateAndInstallServer: Validation successful', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Start proxy if needed + if (! $this->server->isBuildServer()) { + $proxyShouldRun = CheckProxy::run($this->server, true); + if ($proxyShouldRun) { + StartProxy::dispatch($this->server); + } + } + + // Mark validation as complete + $this->server->update(['is_validating' => false]); + + // Refresh server to get latest state + $this->server->refresh(); + + // Broadcast events to update UI + ServerValidated::dispatch($this->server->team_id, $this->server->uuid); + ServerReachabilityChanged::dispatch($this->server); + + } catch (\Throwable $e) { + Log::error('ValidateAndInstallServer: Exception occurred', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->server->update([ + 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(), + 'is_validating' => false, + ]); + } + } +} diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 87008e45e..680ac7701 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -617,7 +617,14 @@ private function loadSearchableItems() 'type' => 'navigation', 'description' => 'Manage private keys and API tokens', 'link' => route('security.private-key.index'), - 'search_text' => 'security private keys ssh api tokens', + 'search_text' => 'security private keys ssh api tokens cloud-init scripts', + ], + [ + 'name' => 'Cloud-Init Scripts', + 'type' => 'navigation', + 'description' => 'Manage reusable cloud-init scripts', + 'link' => route('security.cloud-init-scripts'), + 'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup', ], [ 'name' => 'Sources', diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php new file mode 100644 index 000000000..cf4e71105 --- /dev/null +++ b/app/Livewire/Notifications/Webhook.php @@ -0,0 +1,196 @@ +team = auth()->user()->currentTeam(); + $this->settings = $this->team->webhookNotificationSettings; + $this->authorize('view', $this->settings); + $this->syncData(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->validate(); + $this->authorize('update', $this->settings); + $this->settings->webhook_enabled = $this->webhookEnabled; + $this->settings->webhook_url = $this->webhookUrl; + + $this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications; + $this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications; + $this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications; + $this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications; + $this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications; + $this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications; + $this->settings->scheduled_task_failure_webhook_notifications = $this->scheduledTaskFailureWebhookNotifications; + $this->settings->docker_cleanup_success_webhook_notifications = $this->dockerCleanupSuccessWebhookNotifications; + $this->settings->docker_cleanup_failure_webhook_notifications = $this->dockerCleanupFailureWebhookNotifications; + $this->settings->server_disk_usage_webhook_notifications = $this->serverDiskUsageWebhookNotifications; + $this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications; + $this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications; + $this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications; + + $this->settings->save(); + refreshSession(); + } else { + $this->webhookEnabled = $this->settings->webhook_enabled; + $this->webhookUrl = $this->settings->webhook_url; + + $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; + $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; + $this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications; + $this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications; + $this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications; + $this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications; + $this->scheduledTaskFailureWebhookNotifications = $this->settings->scheduled_task_failure_webhook_notifications; + $this->dockerCleanupSuccessWebhookNotifications = $this->settings->docker_cleanup_success_webhook_notifications; + $this->dockerCleanupFailureWebhookNotifications = $this->settings->docker_cleanup_failure_webhook_notifications; + $this->serverDiskUsageWebhookNotifications = $this->settings->server_disk_usage_webhook_notifications; + $this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications; + $this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications; + $this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications; + } + } + + public function instantSaveWebhookEnabled() + { + try { + $original = $this->webhookEnabled; + $this->validate([ + 'webhookUrl' => 'required', + ], [ + 'webhookUrl.required' => 'Webhook URL is required.', + ]); + $this->saveModel(); + } catch (\Throwable $e) { + $this->webhookEnabled = $original; + + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $this->syncData(true); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submit() + { + try { + $this->resetErrorBag(); + $this->syncData(true); + $this->saveModel(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function saveModel() + { + $this->syncData(true); + refreshSession(); + + if (isDev()) { + ray('Webhook settings saved', [ + 'webhook_enabled' => $this->settings->webhook_enabled, + 'webhook_url' => $this->settings->webhook_url, + ]); + } + + $this->dispatch('success', 'Settings saved.'); + } + + public function sendTestNotification() + { + try { + $this->authorize('sendTest', $this->settings); + + if (isDev()) { + ray('Sending test webhook notification', [ + 'team_id' => $this->team->id, + 'webhook_url' => $this->settings->webhook_url, + ]); + } + + $this->team->notify(new Test(channel: 'webhook')); + $this->dispatch('success', 'Test notification sent.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.notifications.webhook'); + } +} diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index fabbc7cb4..4b93e69d7 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -6,7 +6,6 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneDragonfly; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -249,13 +248,13 @@ public function regenerateSslCertificate() $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id) + $caCert = $server->sslCertificates() ->where('is_ca_certificate', true) ->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 7502d001d..91952533f 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -6,7 +6,6 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneKeydb; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -255,7 +254,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id) + $caCert = $this->server->sslCertificates() ->where('is_ca_certificate', true) ->first(); diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index c82c4538f..3ec6a9954 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -6,7 +6,6 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneMariadb; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -207,7 +206,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 4fbc45437..be52cfa7a 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -6,7 +6,6 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneMongodb; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -215,7 +214,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index ada1b3a2c..e6b0ead24 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -6,7 +6,6 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneMysql; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -215,7 +214,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 2d37620b9..06c16a658 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -6,7 +6,6 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -169,7 +168,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 1eb4f5c8d..4cb93e836 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -6,7 +6,6 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -209,7 +208,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->commonName, diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php new file mode 100644 index 000000000..33beff334 --- /dev/null +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -0,0 +1,101 @@ +scriptId = $scriptId; + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('update', $cloudInitScript); + + $this->name = $cloudInitScript->name; + $this->script = $cloudInitScript->script; + } else { + $this->authorize('create', CloudInitScript::class); + } + } + + protected function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function save() + { + $this->validate(); + + try { + if ($this->scriptId) { + // Update existing script + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId); + $this->authorize('update', $cloudInitScript); + + $cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + $message = 'Cloud-init script updated successfully.'; + } else { + // Create new script + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->name, + 'script' => $this->script, + ]); + + $message = 'Cloud-init script created successfully.'; + } + + // Only reset fields if creating (not editing) + if (! $this->scriptId) { + $this->reset(['name', 'script']); + } + + $this->dispatch('scriptSaved'); + $this->dispatch('success', $message); + + if ($this->modal_mode) { + $this->dispatch('closeModal'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-init-script-form'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php new file mode 100644 index 000000000..13bcf2caa --- /dev/null +++ b/app/Livewire/Security/CloudInitScripts.php @@ -0,0 +1,52 @@ +authorize('viewAny', CloudInitScript::class); + $this->loadScripts(); + } + + public function getListeners() + { + return [ + 'scriptSaved' => 'loadScripts', + ]; + } + + public function loadScripts() + { + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); + } + + public function deleteScript(int $scriptId) + { + try { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('delete', $script); + + $script->delete(); + $this->loadScripts(); + + $this->dispatch('success', 'Cloud-init script deleted successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-init-scripts'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php new file mode 100644 index 000000000..7affb1531 --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -0,0 +1,99 @@ +authorize('create', CloudProviderToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => 'required|string|in:hetzner,digitalocean', + 'token' => 'required|string', + 'name' => 'required|string|max:255', + ]; + } + + protected function messages(): array + { + return [ + 'provider.required' => 'Please select a cloud provider.', + 'provider.in' => 'Invalid cloud provider selected.', + 'token.required' => 'API token is required.', + 'name.required' => 'Token name is required.', + ]; + } + + private function validateToken(string $provider, string $token): bool + { + try { + if ($provider === 'hetzner') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ray($response); + + return $response->successful(); + } + + // Add other providers here in the future + // if ($provider === 'digitalocean') { ... } + + return false; + } catch (\Throwable $e) { + return false; + } + } + + public function addToken() + { + $this->validate(); + + try { + // Validate the token with the provider's API + if (! $this->validateToken($this->provider, $this->token)) { + return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); + } + + $savedToken = CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => $this->provider, + 'token' => $this->token, + 'name' => $this->name, + ]); + + $this->reset(['token', 'name']); + + // Dispatch event with token ID so parent components can react + $this->dispatch('tokenAdded', tokenId: $savedToken->id); + + $this->dispatch('success', 'Cloud provider token added successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token-form'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php new file mode 100644 index 000000000..f05b3c0ca --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -0,0 +1,60 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'loadTokens', + ]; + } + + public function loadTokens() + { + $this->tokens = CloudProviderToken::ownedByCurrentTeam()->get(); + } + + public function deleteToken(int $tokenId) + { + try { + $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId); + $this->authorize('delete', $token); + + // Check if any servers are using this token + if ($token->hasServers()) { + $serverCount = $token->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return; + } + + $token->delete(); + $this->loadTokens(); + + $this->dispatch('success', 'Cloud provider token deleted successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-tokens'); + } +} diff --git a/app/Livewire/Security/CloudTokens.php b/app/Livewire/Security/CloudTokens.php new file mode 100644 index 000000000..d6d1333f1 --- /dev/null +++ b/app/Livewire/Security/CloudTokens.php @@ -0,0 +1,13 @@ + currentTeam()->id, ]); + // If in modal mode, dispatch event and don't redirect + if ($this->modal_mode) { + $this->dispatch('privateKeyCreated', keyId: $privateKey->id); + $this->dispatch('success', 'Private key created successfully.'); + + return; + } + return $this->redirectAfterCreation($privateKey); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php index 039b5f71d..c929d9b3d 100644 --- a/app/Livewire/Server/CaCertificate/Show.php +++ b/app/Livewire/Server/CaCertificate/Show.php @@ -39,7 +39,7 @@ public function mount(string $server_uuid) public function loadCaCertificate() { - $this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first(); + $this->caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); if ($this->caCertificate) { $this->certificateContent = $this->caCertificate->ssl_certificate; diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php new file mode 100644 index 000000000..6b22fddc6 --- /dev/null +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -0,0 +1,144 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->loadTokens(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + ]; + } + + public function loadTokens() + { + $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + $this->loadTokens(); + } + + public function setCloudProviderToken($tokenId) + { + $ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId); + if (is_null($ownedToken)) { + $this->dispatch('error', 'You are not allowed to use this token.'); + + return; + } + try { + $this->authorize('update', $this->server); + + // Validate the token works and can access this specific server + $validationResult = $this->validateTokenForServer($ownedToken); + if (! $validationResult['valid']) { + $this->dispatch('error', $validationResult['error']); + + return; + } + + $this->server->cloudProviderToken()->associate($ownedToken); + $this->server->save(); + $this->dispatch('success', 'Hetzner token updated successfully.'); + $this->dispatch('refreshServerShow'); + } catch (\Exception $e) { + $this->server->refresh(); + $this->dispatch('error', $e->getMessage()); + } + } + + private function validateTokenForServer(CloudProviderToken $token): array + { + try { + // First, validate the token itself + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if (! $response->successful()) { + return [ + 'valid' => false, + 'error' => 'This token is invalid or has insufficient permissions.', + ]; + } + + // Check if this token can access the specific Hetzner server + if ($this->server->hetzner_server_id) { + $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this server. It may belong to a different Hetzner project.', + ]; + } + } + + return ['valid' => true]; + } catch (\Throwable $e) { + return [ + 'valid' => false, + 'error' => 'Failed to validate token: '.$e->getMessage(), + ]; + } + } + + public function validateToken() + { + try { + $token = $this->server->cloudProviderToken; + if (! $token) { + $this->dispatch('error', 'No Hetzner token is associated with this server.'); + + return; + } + + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if ($response->successful()) { + $this->dispatch('success', 'Hetzner token is valid and working.'); + } else { + $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 2d4ba4430..cf77664fe 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Team; use Livewire\Component; @@ -12,6 +13,8 @@ class Create extends Component public bool $limit_reached = false; + public bool $has_hetzner_tokens = false; + public function mount() { $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); @@ -21,6 +24,11 @@ public function mount() return; } $this->limit_reached = Team::serverLimitReached(); + + // Check if user has Hetzner tokens + $this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->exists(); } public function render() diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index b9e3944b5..8c2c54c99 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -16,6 +16,8 @@ class Delete extends Component public Server $server; + public bool $delete_from_hetzner = false; + public function mount(string $server_uuid) { try { @@ -41,8 +43,15 @@ public function delete($password) return; } + $this->server->delete(); - DeleteServer::dispatch($this->server); + DeleteServer::dispatch( + $this->server->id, + $this->delete_from_hetzner, + $this->server->hetzner_server_id, + $this->server->cloud_provider_token_id, + $this->server->team_id + ); return redirect()->route('server.index'); } catch (\Throwable $e) { @@ -52,6 +61,18 @@ public function delete($password) public function render() { - return view('livewire.server.delete'); + $checkboxes = []; + + if ($this->server->hetzner_server_id) { + $checkboxes[] = [ + 'id' => 'delete_from_hetzner', + 'label' => 'Also delete server from Hetzner Cloud', + 'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.', + ]; + } + + return view('livewire.server.delete', [ + 'checkboxes' => $checkboxes, + ]); } } diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index beefed12a..6baa54672 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -118,17 +118,31 @@ public function checkProxyStatus() public function showNotification() { + $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; - $forceStop = $this->server->proxy->force_stop ?? false; switch ($this->proxyStatus) { case 'running': $this->loadProxyConfiguration(); + $this->dispatch('success', 'Proxy is running.'); break; case 'restarting': $this->dispatch('info', 'Initiating proxy restart.'); break; + case 'exited': + $this->dispatch('info', 'Proxy has exited.'); + break; + case 'stopping': + $this->dispatch('info', 'Proxy is stopping.'); + break; + case 'starting': + $this->dispatch('info', 'Proxy is starting.'); + break; + case 'unknown': + $this->dispatch('info', 'Proxy status is unknown.'); + break; default: + $this->dispatch('info', 'Proxy status updated.'); break; } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php new file mode 100644 index 000000000..f3368b4eb --- /dev/null +++ b/app/Livewire/Server/New/ByHetzner.php @@ -0,0 +1,565 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + } + + public function loadSavedCloudInitScripts() + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function resetSelection() + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + } + + public function loadTokens() + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + // Refresh token list + $this->loadTokens(); + + // Auto-select the new token + $this->selected_token_id = $tokenId; + + // Automatically proceed to next step + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId) + { + // Refresh private keys list + $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); + + // Auto-select the new key + $this->private_key_id = $keyId; + + // Clear validation errors for private_key_id + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_location' => 'required|string', + 'selected_image' => 'required|integer', + 'selected_server_type' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedHetznerSshKeyIds' => 'nullable|array', + 'selectedHetznerSshKeyIds.*' => 'integer', + 'enable_ipv4' => 'required|boolean', + 'enable_ipv6' => 'required|boolean', + 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Hetzner token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId) + { + $this->selected_token_id = $tokenId; + } + + private function validateHetznerToken(string $token): bool + { + try { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + return $response->successful(); + } catch (\Throwable $e) { + return false; + } + } + + private function getHetznerToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + public function nextStep() + { + // Validate step 1 - just need a token selected + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + $hetznerToken = $this->getHetznerToken(); + + if (! $hetznerToken) { + return $this->dispatch('error', 'Please select a valid Hetzner token.'); + } + + // Load Hetzner data + $this->loadHetznerData($hetznerToken); + + // Move to step 2 + $this->current_step = 2; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function previousStep() + { + $this->current_step = 1; + } + + private function loadHetznerData(string $token) + { + $this->loading_data = true; + + try { + $hetznerService = new HetznerService($token); + + $this->locations = $hetznerService->getLocations(); + $this->serverTypes = $hetznerService->getServerTypes(); + + // Get images and sort by name + $images = $hetznerService->getImages(); + + ray('Raw images from Hetzner API', [ + 'total_count' => count($images), + 'types' => collect($images)->pluck('type')->unique()->values(), + 'sample' => array_slice($images, 0, 3), + ]); + + $this->images = collect($images) + ->filter(function ($image) { + // Only system images + if (! isset($image['type']) || $image['type'] !== 'system') { + return false; + } + + // Filter out deprecated images + if (isset($image['deprecated']) && $image['deprecated'] === true) { + return false; + } + + return true; + }) + ->sortBy('name') + ->values() + ->toArray(); + + ray('Filtered images', [ + 'filtered_count' => count($this->images), + 'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(), + ]); + + // Load SSH keys from Hetzner + $this->hetznerSshKeys = $hetznerService->getSshKeys(); + + ray('Hetzner SSH Keys', [ + 'total_count' => count($this->hetznerSshKeys), + 'keys' => $this->hetznerSshKeys, + ]); + + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + throw $e; + } + } + + public function getAvailableServerTypesProperty() + { + ray('Getting available server types', [ + 'selected_location' => $this->selected_location, + 'total_server_types' => count($this->serverTypes), + ]); + + if (! $this->selected_location) { + return $this->serverTypes; + } + + $filtered = collect($this->serverTypes) + ->filter(function ($type) { + if (! isset($type['locations'])) { + return false; + } + + $locationNames = collect($type['locations'])->pluck('name')->toArray(); + + return in_array($this->selected_location, $locationNames); + }) + ->values() + ->toArray(); + + ray('Filtered server types', [ + 'selected_location' => $this->selected_location, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function getAvailableImagesProperty() + { + ray('Getting available images', [ + 'selected_server_type' => $this->selected_server_type, + 'total_images' => count($this->images), + 'images' => $this->images, + ]); + + if (! $this->selected_server_type) { + return $this->images; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + ray('Server type data', $serverType); + + if (! $serverType || ! isset($serverType['architecture'])) { + ray('No architecture in server type, returning all'); + + return $this->images; + } + + $architecture = $serverType['architecture']; + + $filtered = collect($this->images) + ->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture) + ->values() + ->toArray(); + + ray('Filtered images', [ + 'architecture' => $architecture, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function getSelectedServerPriceProperty(): ?string + { + if (! $this->selected_server_type) { + return null; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) { + return null; + } + + $price = $serverType['prices'][0]['price_monthly']['gross']; + + return '€'.number_format($price, 2); + } + + public function updatedSelectedLocation($value) + { + ray('Location selected', $value); + + // Reset server type and image when location changes + $this->selected_server_type = null; + $this->selected_image = null; + } + + public function updatedSelectedServerType($value) + { + ray('Server type selected', $value); + + // Reset image when server type changes + $this->selected_image = null; + } + + public function updatedSelectedImage($value) + { + ray('Image selected', $value); + } + + public function updatedSelectedCloudInitScriptId($value) + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + } + } + + public function clearCloudInitScript() + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + } + + private function createHetznerServer(string $token): array + { + $hetznerService = new HetznerService($token); + + // Get the private key and extract public key + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + + $publicKey = $privateKey->getPublicKey(); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + ray('Private Key Info', [ + 'private_key_id' => $this->private_key_id, + 'sha256_fingerprint' => $privateKey->fingerprint, + 'md5_fingerprint' => $md5Fingerprint, + ]); + + // Check if SSH key already exists on Hetzner by comparing MD5 fingerprints + $existingSshKeys = $hetznerService->getSshKeys(); + $existingKey = null; + + ray('Existing SSH Keys on Hetzner', $existingSshKeys); + + foreach ($existingSshKeys as $key) { + if ($key['fingerprint'] === $md5Fingerprint) { + $existingKey = $key; + break; + } + } + + // Upload SSH key if it doesn't exist + if ($existingKey) { + $sshKeyId = $existingKey['id']; + ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]); + } else { + $sshKeyName = $privateKey->name; + $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); + $sshKeyId = $uploadedKey['id']; + ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]); + } + + // Normalize server name to lowercase for RFC 1123 compliance + $normalizedServerName = strtolower(trim($this->server_name)); + + // Prepare SSH keys array: Coolify key + user-selected Hetzner keys + $sshKeys = array_merge( + [$sshKeyId], // Coolify key (always included) + $this->selectedHetznerSshKeyIds // User-selected Hetzner keys + ); + + // Remove duplicates in case the Coolify key was also selected + $sshKeys = array_unique($sshKeys); + $sshKeys = array_values($sshKeys); // Re-index array + + // Prepare server creation parameters + $params = [ + 'name' => $normalizedServerName, + 'server_type' => $this->selected_server_type, + 'image' => $this->selected_image, + 'location' => $this->selected_location, + 'start_after_create' => true, + 'ssh_keys' => $sshKeys, + 'public_net' => [ + 'enable_ipv4' => $this->enable_ipv4, + 'enable_ipv6' => $this->enable_ipv6, + ], + ]; + + // Add cloud-init script if provided + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + + ray('Server creation parameters', $params); + + // Create server on Hetzner + $hetznerServer = $hetznerService->createServer($params); + + ray('Hetzner server created', $hetznerServer); + + return $hetznerServer; + } + + public function submit() + { + $this->validate(); + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + // Save cloud-init script if requested + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $hetznerToken = $this->getHetznerToken(); + + // Create server on Hetzner + $hetznerServer = $this->createHetznerServer($hetznerToken); + + // Determine IP address to use (prefer IPv4, fallback to IPv6) + $ipAddress = null; + if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; + } elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + } + + if (! $ipAddress) { + throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); + } + + // Create server in Coolify database + $server = Server::create([ + 'name' => $this->server_name, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'hetzner_server_id' => $hetznerServer['id'], + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return redirect()->route('server.show', $server->uuid); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-hetzner'); + } +} diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index db4dc9b88..4626a9135 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -67,13 +67,21 @@ class Show extends Component public string $serverTimezone; + public ?string $hetznerServerStatus = null; + + public bool $hetznerServerManuallyStarted = false; + + public bool $isValidating = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; return [ 'refreshServerShow' => 'refresh', + 'refreshServer' => '$refresh', "echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted', + "echo-private:team.{$teamId},ServerValidated" => 'handleServerValidated', ]; } @@ -138,6 +146,10 @@ public function mount(string $server_uuid) if (! $this->server->isEmpty()) { $this->isBuildServerLocked = true; } + // Load saved Hetzner status and validation state + $this->hetznerServerStatus = $this->server->hetzner_server_status; + $this->isValidating = $this->server->is_validating ?? false; + } catch (\Throwable $e) { return handleError($e, $this); } @@ -218,6 +230,7 @@ public function syncData(bool $toModel = false) $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->serverTimezone = $this->server->settings->server_timezone; + $this->isValidating = $this->server->is_validating ?? false; } } @@ -361,6 +374,87 @@ public function instantSave() } } + public function checkHetznerServerStatus(bool $manual = false) + { + try { + if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); + + return; + } + + $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $serverData = $hetznerService->getServer($this->server->hetzner_server_id); + + $this->hetznerServerStatus = $serverData['status'] ?? null; + + // Save status to database without triggering model events + if ($this->server->hetzner_server_status !== $this->hetznerServerStatus) { + $this->server->hetzner_server_status = $this->hetznerServerStatus; + $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); + } + if ($manual) { + $this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown')); + } + + // If Hetzner server is off but Coolify thinks it's still reachable, update Coolify's state + if ($this->hetznerServerStatus === 'off' && $this->server->settings->is_reachable) { + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); + if ($uptime) { + $this->dispatch('success', 'Server is reachable.'); + $this->server->settings->is_reachable = $this->isReachable = true; + $this->server->settings->is_usable = $this->isUsable = true; + $this->server->settings->save(); + ServerReachabilityChanged::dispatch($this->server); + } else { + $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); + + return; + } + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function handleServerValidated($event = null) + { + // Check if event is for this server + if ($event && isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) { + return; + } + + // Refresh server data + $this->server->refresh(); + $this->syncData(); + + // Update validation state + $this->isValidating = $this->server->is_validating ?? false; + $this->dispatch('refreshServerShow'); + $this->dispatch('refreshServer'); + } + + public function startHetznerServer() + { + try { + if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); + + return; + } + + $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService->powerOnServer($this->server->hetzner_server_id); + + $this->hetznerServerStatus = 'starting'; + $this->server->update(['hetzner_server_status' => 'starting']); + $this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running + $this->dispatch('success', 'Hetzner server is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function submit() { try { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index bf0b7b6a5..bbd7f3dd9 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -4,6 +4,7 @@ use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; +use App\Events\ServerValidated; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -63,6 +64,19 @@ public function startValidatingAfterAsking() $this->init(); } + public function retry() + { + $this->authorize('update', $this->server); + $this->uptime = null; + $this->supported_os_type = null; + $this->docker_installed = null; + $this->docker_compose_installed = null; + $this->docker_version = null; + $this->error = null; + $this->number_of_tries = 0; + $this->init(); + } + public function validateConnection() { $this->authorize('update', $this->server); @@ -136,8 +150,12 @@ public function validateDockerVersion() } else { $this->docker_version = $this->server->validateDockerEngineVersion(); if ($this->docker_version) { + // Mark validation as complete + $this->server->update(['is_validating' => false]); + $this->dispatch('refreshServerShow'); $this->dispatch('refreshBoardingIndex'); + ServerValidated::dispatch($this->server->team_id, $this->server->uuid); $this->dispatch('success', 'Server validated, proxy is starting in a moment.'); $proxyShouldRun = CheckProxy::run($this->server, true); if (! $proxyShouldRun) { diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 03dbc1d91..6bb4c5e90 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -61,6 +61,10 @@ private function getAllActiveContainers() public function updatedSelectedUuid() { + if ($this->selected_uuid === 'default') { + // When cleared to default, do nothing (no error message) + return; + } $this->connectToContainer(); } diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php new file mode 100644 index 000000000..2c78cc582 --- /dev/null +++ b/app/Models/CloudInitScript.php @@ -0,0 +1,33 @@ + 'encrypted', + ]; + } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam(array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + } +} diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php new file mode 100644 index 000000000..607040269 --- /dev/null +++ b/app/Models/CloudProviderToken.php @@ -0,0 +1,41 @@ + 'encrypted', + ]; + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function servers() + { + return $this->hasMany(Server::class); + } + + public function hasServers(): bool + { + return $this->servers()->exists(); + } + + public static function ownedByCurrentTeam(array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + } + + public function scopeForProvider($query, string $provider) + { + return $query->where('provider', $provider); + } +} diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index c210f3c5b..08f3f1ebd 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -289,6 +289,17 @@ public static function generateFingerprint($privateKey) } } + public static function generateMd5Fingerprint($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->getFingerprint('md5'); + } catch (\Throwable $e) { + return null; + } + } + public static function fingerprintExists($fingerprint, $excludeId = null) { $query = self::query() diff --git a/app/Models/Server.php b/app/Models/Server.php index 829a4b5aa..e39526949 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -136,6 +136,7 @@ protected static function booted() $destination->delete(); }); $server->settings()->delete(); + $server->sslCertificates()->delete(); }); } @@ -161,7 +162,11 @@ protected static function booted() 'user', 'description', 'private_key_id', + 'cloud_provider_token_id', 'team_id', + 'hetzner_server_id', + 'hetzner_server_status', + 'is_validating', ]; protected $guarded = []; @@ -889,6 +894,16 @@ public function privateKey() return $this->belongsTo(PrivateKey::class); } + public function cloudProviderToken() + { + return $this->belongsTo(CloudProviderToken::class); + } + + public function sslCertificates() + { + return $this->hasMany(SslCertificate::class); + } + public function muxFilename() { return 'mux_'.$this->uuid; @@ -1327,7 +1342,7 @@ public function generateCaCertificate() isCaCertificate: true, validityDays: 10 * 365 ); - $caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first(); + $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); ray('CA certificate generated', $caCertificate); if ($caCertificate) { $certificateContent = $caCertificate->ssl_certificate; diff --git a/app/Models/Team.php b/app/Models/Team.php index 51fdeffa4..6c30389ee 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -54,6 +54,7 @@ protected static function booted() $team->slackNotificationSettings()->create(); $team->telegramNotificationSettings()->create(); $team->pushoverNotificationSettings()->create(); + $team->webhookNotificationSettings()->create(); }); static::saving(function ($team) { @@ -258,6 +259,11 @@ public function privateKeys() return $this->hasMany(PrivateKey::class); } + public function cloudProviderTokens() + { + return $this->hasMany(CloudProviderToken::class); + } + public function sources() { $sources = collect([]); @@ -307,4 +313,9 @@ public function pushoverNotificationSettings() { return $this->hasOne(PushoverNotificationSettings::class); } + + public function webhookNotificationSettings() + { + return $this->hasOne(WebhookNotificationSettings::class); + } } diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php new file mode 100644 index 000000000..4ca89e0d3 --- /dev/null +++ b/app/Models/WebhookNotificationSettings.php @@ -0,0 +1,64 @@ + 'boolean', + 'webhook_url' => 'encrypted', + + 'deployment_success_webhook_notifications' => 'boolean', + 'deployment_failure_webhook_notifications' => 'boolean', + 'status_change_webhook_notifications' => 'boolean', + 'backup_success_webhook_notifications' => 'boolean', + 'backup_failure_webhook_notifications' => 'boolean', + 'scheduled_task_success_webhook_notifications' => 'boolean', + 'scheduled_task_failure_webhook_notifications' => 'boolean', + 'docker_cleanup_webhook_notifications' => 'boolean', + 'server_disk_usage_webhook_notifications' => 'boolean', + 'server_reachable_webhook_notifications' => 'boolean', + 'server_unreachable_webhook_notifications' => 'boolean', + 'server_patch_webhook_notifications' => 'boolean', + ]; + } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function isEnabled() + { + return $this->webhook_enabled; + } +} diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index dec361e78..8fff7f03b 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -185,4 +185,30 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => false, + 'message' => 'Deployment failed', + 'event' => 'deployment_failed', + 'application_name' => $this->application_name, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $this->deployment_uuid, + 'deployment_url' => $this->deployment_url, + 'project' => data_get($this->application, 'environment.project.name'), + 'environment' => $this->environment_name, + ]; + + if ($this->preview) { + $data['pull_request_id'] = $this->preview->pull_request_id; + $data['preview_fqdn'] = $this->preview->fqdn; + } + + if ($this->fqdn) { + $data['fqdn'] = $this->fqdn; + } + + return $data; + } } diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 9b59d9162..415df5831 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -205,4 +205,30 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => true, + 'message' => 'New version successfully deployed', + 'event' => 'deployment_success', + 'application_name' => $this->application_name, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $this->deployment_uuid, + 'deployment_url' => $this->deployment_url, + 'project' => data_get($this->application, 'environment.project.name'), + 'environment' => $this->environment_name, + ]; + + if ($this->preview) { + $data['pull_request_id'] = $this->preview->pull_request_id; + $data['preview_fqdn'] = $this->preview->fqdn; + } + + if ($this->fqdn) { + $data['fqdn'] = $this->fqdn; + } + + return $data; + } } diff --git a/app/Notifications/Application/StatusChanged.php b/app/Notifications/Application/StatusChanged.php index fab5487ef..ef61b7e6a 100644 --- a/app/Notifications/Application/StatusChanged.php +++ b/app/Notifications/Application/StatusChanged.php @@ -113,4 +113,19 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Application stopped', + 'event' => 'status_changed', + 'application_name' => $this->resource_name, + 'application_uuid' => $this->resource->uuid, + 'url' => $this->resource_url, + 'project' => data_get($this->resource, 'environment.project.name'), + 'environment' => $this->environment_name, + 'fqdn' => $this->fqdn, + ]; + } } diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php new file mode 100644 index 000000000..8c3e74b17 --- /dev/null +++ b/app/Notifications/Channels/WebhookChannel.php @@ -0,0 +1,37 @@ +webhookNotificationSettings; + + if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) { + if (isDev()) { + ray('Webhook notification skipped - not enabled or no URL configured'); + } + + return; + } + + $payload = $notification->toWebhook(); + + if (isDev()) { + ray('Dispatching webhook notification', [ + 'notification' => get_class($notification), + 'url' => $webhookSettings->webhook_url, + 'payload' => $payload, + ]); + } + + SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url); + } +} diff --git a/app/Notifications/Container/ContainerRestarted.php b/app/Notifications/Container/ContainerRestarted.php index f6ae69481..2d7eb58b5 100644 --- a/app/Notifications/Container/ContainerRestarted.php +++ b/app/Notifications/Container/ContainerRestarted.php @@ -102,4 +102,22 @@ public function toSlack(): SlackMessage color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => true, + 'message' => 'Resource restarted automatically', + 'event' => 'container_restarted', + 'container_name' => $this->name, + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + ]; + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php index fc9410a85..f518cd2fd 100644 --- a/app/Notifications/Container/ContainerStopped.php +++ b/app/Notifications/Container/ContainerStopped.php @@ -102,4 +102,22 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => false, + 'message' => 'Resource stopped unexpectedly', + 'event' => 'container_stopped', + 'container_name' => $this->name, + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + ]; + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index a19fb0431..c2b21b1d5 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -88,4 +88,21 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + return [ + 'success' => false, + 'message' => 'Database backup failed', + 'event' => 'backup_failed', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'database_type' => $this->database_name, + 'frequency' => $this->frequency, + 'error_output' => $this->output, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 78bcfafe3..3d2d8ece3 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -85,4 +85,20 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + return [ + 'success' => true, + 'message' => 'Database backup successful', + 'event' => 'backup_success', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'database_type' => $this->database_name, + 'frequency' => $this->frequency, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index 75ae2824c..ee24ef17d 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -113,4 +113,27 @@ public function toSlack(): SlackMessage color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + $data = [ + 'success' => true, + 'message' => 'Database backup succeeded locally, S3 upload failed', + 'event' => 'backup_success_with_s3_warning', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'database_type' => $this->database_name, + 'frequency' => $this->frequency, + 's3_error' => $this->s3_error, + 'url' => $url, + ]; + + if ($this->s3_storage_url) { + $data['s3_storage_url'] = $this->s3_storage_url; + } + + return $data; + } } diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index eb4fc7e79..bd060112a 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -114,4 +114,28 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => false, + 'message' => 'Scheduled task failed', + 'event' => 'task_failed', + 'task_name' => $this->task->name, + 'task_uuid' => $this->task->uuid, + 'output' => $this->output, + ]; + + if ($this->task->application) { + $data['application_uuid'] = $this->task->application->uuid; + } elseif ($this->task->service) { + $data['service_uuid'] = $this->task->service->uuid; + } + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index c45784db2..58c959bd8 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -105,4 +105,28 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => true, + 'message' => 'Scheduled task succeeded', + 'event' => 'task_success', + 'task_name' => $this->task->name, + 'task_uuid' => $this->task->uuid, + 'output' => $this->output, + ]; + + if ($this->task->application) { + $data['application_uuid'] = $this->task->application->uuid; + } elseif ($this->task->service) { + $data['service_uuid'] = $this->task->service->uuid; + } + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/Server/DockerCleanupFailed.php b/app/Notifications/Server/DockerCleanupFailed.php index 0291eed19..9cbdeb488 100644 --- a/app/Notifications/Server/DockerCleanupFailed.php +++ b/app/Notifications/Server/DockerCleanupFailed.php @@ -66,4 +66,19 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => false, + 'message' => 'Docker cleanup job failed', + 'event' => 'docker_cleanup_failed', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'error_message' => $this->message, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Server/DockerCleanupSuccess.php b/app/Notifications/Server/DockerCleanupSuccess.php index 1a652d189..d28f25c6c 100644 --- a/app/Notifications/Server/DockerCleanupSuccess.php +++ b/app/Notifications/Server/DockerCleanupSuccess.php @@ -66,4 +66,19 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => true, + 'message' => 'Docker cleanup job succeeded', + 'event' => 'docker_cleanup_success', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'cleanup_message' => $this->message, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php new file mode 100644 index 000000000..de894331b --- /dev/null +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -0,0 +1,71 @@ +onQueue('high'); + } + + public function via(object $notifiable): array + { + ray('hello'); + ray($notifiable); + + return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}"); + $mail->view('emails.hetzner-deletion-failed', [ + 'hetznerServerId' => $this->hetznerServerId, + 'errorMessage' => $this->errorMessage, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':cross_mark: Coolify: [ACTION REQUIRED] Failed to delete Hetzner server', + description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\n**Error:** {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + color: DiscordMessage::errorColor(), + ); + } + + public function toTelegram(): array + { + return [ + 'message' => "Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + ]; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage( + title: 'Hetzner Server Deletion Failed', + level: 'error', + message: "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check and manually delete if needed.", + ); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage( + title: 'Coolify: [ACTION REQUIRED] Hetzner Server Deletion Failed', + description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + color: SlackMessage::errorColor() + ); + } +} diff --git a/app/Notifications/Server/HighDiskUsage.php b/app/Notifications/Server/HighDiskUsage.php index 983e6d81e..149d1bbc8 100644 --- a/app/Notifications/Server/HighDiskUsage.php +++ b/app/Notifications/Server/HighDiskUsage.php @@ -88,4 +88,18 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'High disk usage detected', + 'event' => 'high_disk_usage', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'disk_usage' => $this->disk_usage, + 'threshold' => $this->server_disk_usage_notification_threshold, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/Reachable.php b/app/Notifications/Server/Reachable.php index e03aef6b7..e64b0af2a 100644 --- a/app/Notifications/Server/Reachable.php +++ b/app/Notifications/Server/Reachable.php @@ -74,4 +74,18 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => true, + 'message' => 'Server revived', + 'event' => 'server_reachable', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index 1686a6f37..4d3053569 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -345,4 +345,47 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + // Handle error case + if (isset($this->patchData['error'])) { + return [ + 'success' => false, + 'message' => 'Failed to check patches', + 'event' => 'server_patch_check_error', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'os_id' => $this->patchData['osId'] ?? 'unknown', + 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', + 'error' => $this->patchData['error'], + 'url' => $this->serverUrl, + ]; + } + + $totalUpdates = $this->patchData['total_updates'] ?? 0; + $updates = $this->patchData['updates'] ?? []; + + // Check for critical packages + $criticalPackages = collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + }); + + return [ + 'success' => false, + 'message' => 'Server patches available', + 'event' => 'server_patch_check', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'total_updates' => $totalUpdates, + 'os_id' => $this->patchData['osId'] ?? 'unknown', + 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', + 'updates' => $updates, + 'critical_packages_count' => $criticalPackages->count(), + 'url' => $this->serverUrl, + ]; + } } diff --git a/app/Notifications/Server/Unreachable.php b/app/Notifications/Server/Unreachable.php index fe90cc610..99742f3b7 100644 --- a/app/Notifications/Server/Unreachable.php +++ b/app/Notifications/Server/Unreachable.php @@ -82,4 +82,18 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => false, + 'message' => 'Server unreachable', + 'event' => 'server_unreachable', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index 0b1d8d6b1..60bc8a0ee 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -7,6 +7,7 @@ use App\Notifications\Channels\PushoverChannel; use App\Notifications\Channels\SlackChannel; use App\Notifications\Channels\TelegramChannel; +use App\Notifications\Channels\WebhookChannel; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; @@ -36,6 +37,7 @@ public function via(object $notifiable): array 'telegram' => [TelegramChannel::class], 'slack' => [SlackChannel::class], 'pushover' => [PushoverChannel::class], + 'webhook' => [WebhookChannel::class], default => [], }; } else { @@ -110,4 +112,14 @@ public function toSlack(): SlackMessage description: 'This is a test Slack notification from Coolify.' ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => 'This is a test webhook notification from Coolify.', + 'event' => 'test', + 'url' => base_url(), + ]; + } } diff --git a/app/Policies/CloudInitScriptPolicy.php b/app/Policies/CloudInitScriptPolicy.php new file mode 100644 index 000000000..0be4f2662 --- /dev/null +++ b/app/Policies/CloudInitScriptPolicy.php @@ -0,0 +1,65 @@ +isAdmin(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Policies/CloudProviderTokenPolicy.php b/app/Policies/CloudProviderTokenPolicy.php new file mode 100644 index 000000000..b7b108ba8 --- /dev/null +++ b/app/Policies/CloudProviderTokenPolicy.php @@ -0,0 +1,65 @@ +isAdmin(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index c017a580e..5d3347936 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -45,6 +45,7 @@ class AuthServiceProvider extends ServiceProvider \App\Models\TelegramNotificationSettings::class => \App\Policies\NotificationPolicy::class, \App\Models\SlackNotificationSettings::class => \App\Policies\NotificationPolicy::class, \App\Models\PushoverNotificationSettings::class => \App\Policies\NotificationPolicy::class, + \App\Models\WebhookNotificationSettings::class => \App\Policies\NotificationPolicy::class, // API Token policy \Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class, diff --git a/app/Rules/ValidCloudInitYaml.php b/app/Rules/ValidCloudInitYaml.php new file mode 100644 index 000000000..8116e1161 --- /dev/null +++ b/app/Rules/ValidCloudInitYaml.php @@ -0,0 +1,55 @@ +getMessage()); + } + + return; + } + + // If it doesn't start with #! or #cloud-config, try to parse as YAML + // (some users might omit the #cloud-config header) + try { + Yaml::parse($script); + } catch (ParseException $e) { + $fail('The :attribute must be either a valid bash script (starting with #!) or valid cloud-config YAML. YAML parse error: '.$e->getMessage()); + } + } +} diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php new file mode 100644 index 000000000..b6b2b8d32 --- /dev/null +++ b/app/Rules/ValidHostname.php @@ -0,0 +1,114 @@ + 253) { + $fail('The :attribute must not exceed 253 characters.'); + + return; + } + + // Check for dangerous shell metacharacters + $dangerousChars = [ + ';', '|', '&', '$', '`', '(', ')', '{', '}', + '<', '>', '\n', '\r', '\0', '"', "'", '\\', + '!', '*', '?', '[', ']', '~', '^', ':', '#', + '@', '%', '=', '+', ',', ' ', + ]; + + foreach ($dangerousChars as $char) { + if (str_contains($hostname, $char)) { + try { + $logData = [ + 'hostname' => $hostname, + 'character' => $char, + ]; + + if (function_exists('request') && app()->has('request')) { + $logData['ip'] = request()->ip(); + } + + if (function_exists('auth') && app()->has('auth')) { + $logData['user_id'] = auth()->id(); + } + + Log::warning('Hostname validation failed - dangerous character', $logData); + } catch (\Throwable $e) { + // Ignore errors when facades are not available (e.g., in unit tests) + } + + $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + + return; + } + } + + // Additional validation: hostname should not start or end with a dot + if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) { + $fail('The :attribute cannot start or end with a dot.'); + + return; + } + + // Check for consecutive dots + if (str_contains($hostname, '..')) { + $fail('The :attribute cannot contain consecutive dots.'); + + return; + } + + // Split into labels (segments between dots) + $labels = explode('.', $hostname); + + foreach ($labels as $label) { + // Check label length (RFC 1123: max 63 characters per label) + if (strlen($label) < 1 || strlen($label) > 63) { + $fail('The :attribute contains an invalid label. Each segment must be 1-63 characters.'); + + return; + } + + // Check if label starts or ends with hyphen + if (str_starts_with($label, '-') || str_ends_with($label, '-')) { + $fail('The :attribute contains an invalid label. Labels cannot start or end with a hyphen.'); + + return; + } + + // Check if label contains only valid characters (lowercase letters, digits, hyphens) + if (! preg_match('/^[a-z0-9-]+$/', $label)) { + $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + + return; + } + + // RFC 1123 allows labels to be all numeric (unlike RFC 952) + // So we don't need to check for all-numeric labels + } + } +} diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php new file mode 100644 index 000000000..aa6de3897 --- /dev/null +++ b/app/Services/HetznerService.php @@ -0,0 +1,143 @@ +token = $token; + } + + private function request(string $method, string $endpoint, array $data = []) + { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + ]) + ->timeout(30) + ->retry(3, function (int $attempt, \Exception $exception) { + // Handle rate limiting (429 Too Many Requests) + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $response = $exception->response; + + if ($response && $response->status() === 429) { + // Get rate limit reset timestamp from headers + $resetTime = $response->header('RateLimit-Reset'); + + if ($resetTime) { + // Calculate wait time until rate limit resets + $waitSeconds = max(0, $resetTime - time()); + + // Cap wait time at 60 seconds for safety + return min($waitSeconds, 60) * 1000; + } + } + } + + // Exponential backoff for other retriable errors: 100ms, 200ms, 400ms + return $attempt * 100; + }) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error')); + } + + return $response->json(); + } + + private function requestPaginated(string $method, string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $page = 1; + + do { + $data['page'] = $page; + $data['per_page'] = 50; + + $response = $this->request($method, $endpoint, $data); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $nextPage = $response['meta']['pagination']['next_page'] ?? null; + $page = $nextPage; + } while ($nextPage !== null); + + return $allResults; + } + + public function getLocations(): array + { + return $this->requestPaginated('get', '/locations', 'locations'); + } + + public function getImages(): array + { + return $this->requestPaginated('get', '/images', 'images', [ + 'type' => 'system', + ]); + } + + public function getServerTypes(): array + { + return $this->requestPaginated('get', '/server_types', 'server_types'); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/ssh_keys', [ + 'name' => $name, + 'public_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createServer(array $params): array + { + ray('Hetzner createServer request', [ + 'endpoint' => '/servers', + 'params' => $params, + ]); + + $response = $this->request('post', '/servers', $params); + + ray('Hetzner createServer response', [ + 'response' => $response, + ]); + + return $response['server'] ?? []; + } + + public function getServer(int $serverId): array + { + $response = $this->request('get', "/servers/{$serverId}"); + + return $response['server'] ?? []; + } + + public function powerOnServer(int $serverId): array + { + $response = $this->request('post', "/servers/{$serverId}/actions/poweron"); + + return $response['action'] ?? []; + } + + public function deleteServer(int $serverId): void + { + $this->request('delete', "/servers/{$serverId}"); + } +} diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php index 236e4d97c..fded435fd 100644 --- a/app/Traits/HasNotificationSettings.php +++ b/app/Traits/HasNotificationSettings.php @@ -7,6 +7,7 @@ use App\Notifications\Channels\PushoverChannel; use App\Notifications\Channels\SlackChannel; use App\Notifications\Channels\TelegramChannel; +use App\Notifications\Channels\WebhookChannel; use Illuminate\Database\Eloquent\Model; trait HasNotificationSettings @@ -17,6 +18,7 @@ trait HasNotificationSettings 'general', 'test', 'ssl_certificate_renewal', + 'hetzner_deletion_failure', ]; /** @@ -30,6 +32,7 @@ public function getNotificationSettings(string $channel): ?Model 'telegram' => $this->telegramNotificationSettings, 'slack' => $this->slackNotificationSettings, 'pushover' => $this->pushoverNotificationSettings, + 'webhook' => $this->webhookNotificationSettings, default => null, }; } @@ -77,6 +80,7 @@ public function getEnabledChannels(string $event): array 'telegram' => TelegramChannel::class, 'slack' => SlackChannel::class, 'pushover' => PushoverChannel::class, + 'webhook' => WebhookChannel::class, ]; if ($event === 'general') { diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php index ece7f0e35..88f858ec9 100644 --- a/app/View/Components/Forms/Checkbox.php +++ b/app/View/Components/Forms/Checkbox.php @@ -22,7 +22,7 @@ public function __construct( public string|bool|null $checked = false, public string|bool $instantSave = false, public bool $disabled = false, - public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 focus:ring-warning dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed', + public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base', public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php index 25643753d..33e264e37 100644 --- a/app/View/Components/Forms/Datalist.php +++ b/app/View/Components/Forms/Datalist.php @@ -4,7 +4,7 @@ use Closure; use Illuminate\Contracts\View\View; -use Illuminate\Support\Str; +use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; use Visus\Cuid2\Cuid2; @@ -19,9 +19,27 @@ public function __construct( public ?string $label = null, public ?string $helper = null, public bool $required = false, - public string $defaultClass = 'input' + public bool $disabled = false, + public bool $readonly = false, + public bool $multiple = false, + public string|bool $instantSave = false, + public ?string $value = null, + public ?string $placeholder = null, + public bool $autofocus = false, + public string $defaultClass = 'input', + public ?string $canGate = null, + public mixed $canResource = null, + public bool $autoDisable = true, ) { - // + // Handle authorization-based disabling + if ($this->canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + $this->instantSave = false; // Disable instant save for unauthorized users + } + } } /** @@ -36,8 +54,6 @@ public function render(): View|Closure|string $this->name = $this->id; } - $this->label = Str::title($this->label); - return view('components.forms.datalist'); } } diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php index e52bbcc16..e57354037 100644 --- a/database/migrations/2025_10_03_154100_update_clickhouse_image.php +++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php @@ -1,32 +1,32 @@ -string('image')->default('bitnamilegacy/clickhouse')->change(); - }); - // Optionally, update any existing rows with the old default to the new one - DB::table('standalone_clickhouses') - ->where('image', 'bitnami/clickhouse') - ->update(['image' => 'bitnamilegacy/clickhouse']); - } - - public function down() - { - Schema::table('standalone_clickhouses', function (Blueprint $table) { - $table->string('image')->default('bitnami/clickhouse')->change(); - }); - // Optionally, revert any changed values - DB::table('standalone_clickhouses') - ->where('image', 'bitnamilegacy/clickhouse') - ->update(['image' => 'bitnami/clickhouse']); - } -}; \ No newline at end of file +string('image')->default('bitnamilegacy/clickhouse')->change(); + }); + // Optionally, update any existing rows with the old default to the new one + DB::table('standalone_clickhouses') + ->where('image', 'bitnami/clickhouse') + ->update(['image' => 'bitnamilegacy/clickhouse']); + } + + public function down() + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('bitnami/clickhouse')->change(); + }); + // Optionally, revert any changed values + DB::table('standalone_clickhouses') + ->where('image', 'bitnamilegacy/clickhouse') + ->update(['image' => 'bitnami/clickhouse']); + } +}; diff --git a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php new file mode 100644 index 000000000..2c92b0e19 --- /dev/null +++ b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('provider'); + $table->text('token'); + $table->string('name')->nullable(); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_provider_tokens'); + } +}; diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php new file mode 100644 index 000000000..b1c9ec48b --- /dev/null +++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php @@ -0,0 +1,28 @@ +bigInteger('hetzner_server_id')->nullable()->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_id'); + }); + } +}; diff --git a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php new file mode 100644 index 000000000..a25a4ce83 --- /dev/null +++ b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php @@ -0,0 +1,29 @@ +foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['cloud_provider_token_id']); + $table->dropColumn('cloud_provider_token_id'); + }); + } +}; diff --git a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php new file mode 100644 index 000000000..d94c9c76f --- /dev/null +++ b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php @@ -0,0 +1,28 @@ +string('hetzner_server_status')->nullable()->after('hetzner_server_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_status'); + }); + } +}; diff --git a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php new file mode 100644 index 000000000..ddb655d2c --- /dev/null +++ b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php @@ -0,0 +1,28 @@ +boolean('is_validating')->default(false)->after('hetzner_server_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('is_validating'); + }); + } +}; diff --git a/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php b/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php new file mode 100644 index 000000000..fe216a57d --- /dev/null +++ b/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('script'); // Encrypted in the model + $table->timestamps(); + + $table->index('team_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_init_scripts'); + } +}; diff --git a/database/migrations/2025_10_10_120000_create_webhook_notification_settings_table.php b/database/migrations/2025_10_10_120000_create_webhook_notification_settings_table.php new file mode 100644 index 000000000..a3edacbf9 --- /dev/null +++ b/database/migrations/2025_10_10_120000_create_webhook_notification_settings_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + + $table->boolean('webhook_enabled')->default(false); + $table->text('webhook_url')->nullable(); + + $table->boolean('deployment_success_webhook_notifications')->default(false); + $table->boolean('deployment_failure_webhook_notifications')->default(true); + $table->boolean('status_change_webhook_notifications')->default(false); + $table->boolean('backup_success_webhook_notifications')->default(false); + $table->boolean('backup_failure_webhook_notifications')->default(true); + $table->boolean('scheduled_task_success_webhook_notifications')->default(false); + $table->boolean('scheduled_task_failure_webhook_notifications')->default(true); + $table->boolean('docker_cleanup_success_webhook_notifications')->default(false); + $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true); + $table->boolean('server_disk_usage_webhook_notifications')->default(true); + $table->boolean('server_reachable_webhook_notifications')->default(false); + $table->boolean('server_unreachable_webhook_notifications')->default(true); + $table->boolean('server_patch_webhook_notifications')->default(false); + + $table->unique(['team_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_notification_settings'); + } +}; diff --git a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php b/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php new file mode 100644 index 000000000..de2707557 --- /dev/null +++ b/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php @@ -0,0 +1,47 @@ +get(); + + foreach ($teams as $team) { + DB::table('webhook_notification_settings')->updateOrInsert( + ['team_id' => $team->id], + [ + 'webhook_enabled' => false, + 'webhook_url' => null, + 'deployment_success_webhook_notifications' => false, + 'deployment_failure_webhook_notifications' => true, + 'status_change_webhook_notifications' => false, + 'backup_success_webhook_notifications' => false, + 'backup_failure_webhook_notifications' => true, + 'scheduled_task_success_webhook_notifications' => false, + 'scheduled_task_failure_webhook_notifications' => true, + 'docker_cleanup_success_webhook_notifications' => false, + 'docker_cleanup_failure_webhook_notifications' => true, + 'server_disk_usage_webhook_notifications' => true, + 'server_reachable_webhook_notifications' => false, + 'server_unreachable_webhook_notifications' => true, + 'server_patch_webhook_notifications' => false, + ] + ); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // We don't need to do anything in down() since the webhook_notification_settings + // table will be dropped by the create migration's down() method + } +}; diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php index 09f6cc984..1b71a5e43 100644 --- a/database/seeders/CaSslCertSeeder.php +++ b/database/seeders/CaSslCertSeeder.php @@ -4,7 +4,6 @@ use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use Illuminate\Database\Seeder; class CaSslCertSeeder extends Seeder @@ -13,7 +12,7 @@ public function run() { Server::chunk(200, function ($servers) { foreach ($servers as $server) { - $existingCaCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $existingCaCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $existingCaCert) { $caCert = SslHelper::generateSslCertificate( diff --git a/public/svgs/hetzner.svg b/public/svgs/hetzner.svg new file mode 100644 index 000000000..68b1b868d --- /dev/null +++ b/public/svgs/hetzner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index c1dc7e56d..fa1e61cb2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -18,7 +18,7 @@ @theme { --color-base: #101010; --color-warning: #fcd452; - --color-success: #16a34a; + --color-success: #22C55E; --color-error: #dc2626; --color-coollabs-50: #f5f0ff; --color-coollabs: #6b16ed; diff --git a/resources/css/utilities.css b/resources/css/utilities.css index bedfb51bc..1a95de03a 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -32,7 +32,7 @@ @utility apexcharts-tooltip-custom-title { } @utility input-sticky { - @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; + @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; } @utility input-sticky-active { @@ -41,7 +41,7 @@ @utility input-sticky-active { /* Focus */ @utility input-focus { - @apply focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; } /* input, select before */ @@ -52,18 +52,18 @@ @utility input-select { /* Readonly */ @utility input { @apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200; - @apply input-focus; @apply input-select; + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; } @utility select { @apply w-full; - @apply input-focus; @apply input-select; + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; } @utility button { - @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100; + @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; } @utility alert-success { diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index c9710b728..7f9ffefec 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -1,6 +1,6 @@
-
+@endif + +@error($id) + +@enderror diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 858f5ac1c..f6c86f177 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -28,8 +28,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov merge(['class' => $defaultClass]) }} @required($required) @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" + wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}" @@ -40,8 +39,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly) @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" + wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}" max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" maxlength="{{ $attributes->get('maxlength') }}" diff --git a/resources/views/components/forms/select.blade.php b/resources/views/components/forms/select.blade.php index 508a85e0c..3c8eea25a 100644 --- a/resources/views/components/forms/select.blade.php +++ b/resources/views/components/forms/select.blade.php @@ -11,8 +11,7 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu @endif diff --git a/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index b4dec192a..a1c57e775 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -46,8 +46,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer merge(['class' => $defaultClassInput]) }} @required($required) @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" + wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}"> @@ -56,7 +55,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer @if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}" @else wire:model={{ $value ?? $id }} - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' wire:dirty.class="dark:focus:ring-warning dark:ring-warning" @endif + wire:dirty.class="dark:ring-warning ring-warning" @endif @disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}" name="{{ $name }}" name={{ $id }}> @@ -68,7 +67,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer @if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}" @else wire:model={{ $value ?? $id }} - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' wire:dirty.class="dark:focus:ring-warning dark:ring-warning" @endif + wire:dirty.class="dark:ring-warning ring-warning" @endif @disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}" name="{{ $name }}" name={{ $id }}> @endif diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 103f18316..46164840d 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -250,6 +250,18 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300"> {{ $checkbox['label'] }} + @if (isset($checkbox['default_warning'])) + + @endif @endforeach @if (!$disableTwoStepConfirmation) diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index c15985d03..6291e8774 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,8 +8,11 @@ 'content' => null, 'closeOutside' => true, 'minWidth' => '36rem', + 'isFullWidth' => false, ]) -
@if ($content)
@@ -17,13 +20,13 @@ class="relative w-auto h-auto" wire:ignore>
@else @if ($disabled) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isErrorButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isHighlightedButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @else - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @endif @endif
- + \ No newline at end of file diff --git a/resources/views/livewire/notifications/webhook.blade.php b/resources/views/livewire/notifications/webhook.blade.php new file mode 100644 index 000000000..4646aaccd --- /dev/null +++ b/resources/views/livewire/notifications/webhook.blade.php @@ -0,0 +1,89 @@ +
+ + Notifications | Coolify + + +
+
+

Webhook

+ + Save + + @if ($webhookEnabled) + + Send Test Notification + + @else + + Send Test Notification + + @endif +
+
+ +
+
+ + +
+
+

Notification Settings

+

+ Select events for which you would like to receive webhook notifications. +

+
+
+

Deployments

+
+ + + +
+
+
+

Backups

+
+ + +
+
+
+

Scheduled Tasks

+
+ + +
+
+
+

Server

+
+ + + + + + +
+
+
+
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index bf6bcf76c..b1f25a584 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -7,7 +7,7 @@

API Tokens

@if (!$isApiEnabled)
API is disabled. If you want to use the API, please enable it in the Settings menu.
+ href="{{ route('settings.advanced') }}" class="underline dark:text-white">Settings menu. @else
Tokens are created with the current team as scope.
diff --git a/resources/views/livewire/security/cloud-init-script-form.blade.php b/resources/views/livewire/security/cloud-init-script-form.blade.php new file mode 100644 index 000000000..83bedffab --- /dev/null +++ b/resources/views/livewire/security/cloud-init-script-form.blade.php @@ -0,0 +1,17 @@ +
+ + + + +
+ @if ($modal_mode) + + Cancel + + @endif + + {{ $scriptId ? 'Update Script' : 'Create Script' }} + +
+ \ No newline at end of file diff --git a/resources/views/livewire/security/cloud-init-scripts.blade.php b/resources/views/livewire/security/cloud-init-scripts.blade.php new file mode 100644 index 000000000..e2013a4fb --- /dev/null +++ b/resources/views/livewire/security/cloud-init-scripts.blade.php @@ -0,0 +1,50 @@ +
+ +
+

Cloud-Init Scripts

+ @can('create', App\Models\CloudInitScript::class) + + + + @endcan +
+
Manage reusable cloud-init scripts for server initialization. Currently working only with Hetzner's integration.
+ +
+ @forelse ($scripts as $script) +
+
+
+
{{ $script->name }}
+
+ Created {{ $script->created_at->diffForHumans() }} +
+
+
+ +
+ @can('update', $script) + + + + @endcan + + @can('delete', $script) + + @endcan +
+
+ @empty +
No cloud-init scripts found. Create one to get started.
+ @endforelse +
+
diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php new file mode 100644 index 000000000..ae25ba995 --- /dev/null +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -0,0 +1,58 @@ +
+
+ @if ($modal_mode) + {{-- Modal layout: vertical, compact --}} + @if (!isset($provider) || empty($provider) || $provider === '') + + + + + @else + + @endif + + + + + + @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty()) +
+ Create an API token in the {{ ucfirst($provider) }} Console → choose + Project → Security → API Tokens. +
+ @endif + + Validate & Add Token + @else + {{-- Full page layout: horizontal, spacious --}} +
+
+ + + + +
+
+ +
+
+
+ + @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty()) +
+ Create an API token in the Hetzner Console → choose Project → Security → API + Tokens. +
+ @endif +
+ Validate & Add Token + @endif + +
diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php new file mode 100644 index 000000000..b3239c4a8 --- /dev/null +++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php @@ -0,0 +1,40 @@ +
+

Cloud Provider Tokens

+
Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.).
+ +

New Token

+ @can('create', App\Models\CloudProviderToken::class) + + @endcan + +

Saved Tokens

+
+ @forelse ($tokens as $savedToken) +
+
+ + {{ strtoupper($savedToken->provider) }} + + {{ $savedToken->name }} +
+
Created: {{ $savedToken->created_at->diffForHumans() }}
+ + @can('delete', $savedToken) + + @endcan +
+ @empty +
+
No cloud provider tokens found.
+
+ @endforelse +
+
diff --git a/resources/views/livewire/security/cloud-tokens.blade.php b/resources/views/livewire/security/cloud-tokens.blade.php new file mode 100644 index 000000000..2edbcd30f --- /dev/null +++ b/resources/views/livewire/security/cloud-tokens.blade.php @@ -0,0 +1,7 @@ +
+ + Cloud Tokens | Coolify + + + +
diff --git a/resources/views/livewire/server/cloud-provider-token/show.blade.php b/resources/views/livewire/server/cloud-provider-token/show.blade.php new file mode 100644 index 000000000..6fb65c411 --- /dev/null +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -0,0 +1,61 @@ +
+ + {{ data_get_str($server, 'name')->limit(10) }} > Hetzner Token | Coolify + + +
+ +
+ @if ($server->hetzner_server_id) +
+

Hetzner Token

+ @can('create', App\Models\CloudProviderToken::class) + + + + @endcan + + Validate token + +
+
Change your server's Hetzner token.
+
+ @forelse ($cloudProviderTokens as $token) +
+
+
{{ $token->name }}
+
+ Created {{ $token->created_at->diffForHumans() }} +
+
+ @if (data_get($server, 'cloudProviderToken.id') !== $token->id) + + Use this token + + @else + + Currently used + + @endif +
+ @empty +
No Hetzner tokens found.
+ @endforelse +
+ @else +
+

Hetzner Token

+
+
This server was not created through Hetzner Cloud integration.
+
+

+ Only servers created through Hetzner Cloud can have their tokens managed here. +

+
+ @endif +
+
+
diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index acab92374..0f178bd34 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -1,3 +1,34 @@
- +
+ @can('viewAny', App\Models\CloudProviderToken::class) +
+ + +
+
+ + + + +
+
Connect a Hetzner Server
+
+ Deploy servers directly from your Hetzner Cloud account +
+
+
+
+
+ +
+
+ +
+ @endcan + +
+

Add Server by IP Address

+ +
+
diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index c61775ee8..073849452 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -15,16 +15,15 @@ @if ($server->definedResources()->count() > 0)
You need to delete all resources before deleting this server.
- - @else - @endif + + @endif diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php new file mode 100644 index 000000000..4e9bcedc2 --- /dev/null +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -0,0 +1,200 @@ +
+ @if ($limit_reached) + + @else + @if ($current_step === 1) +
+ @if ($available_tokens->count() > 0) +
+
+ + + @foreach ($available_tokens as $token) + + @endforeach + +
+
+ + Continue + +
+
+ +
OR
+ @endif + + + + +
+ @elseif ($current_step === 2) + @if ($loading_data) +
+
+
+

Loading Hetzner data...

+
+
+ @else +
+
+ +
+ +
+ + + @foreach ($locations as $location) + + @endforeach + +
+ +
+ + + @foreach ($this->availableServerTypes as $serverType) + + @endforeach + +
+ +
+ + + @foreach ($this->availableImages as $image) + + @endforeach + +
+ +
+ @if ($private_keys->count() === 0) +
+ +
+

+ No private keys found. You need to create a private key to continue. +

+ + + +
+
+ @else + + + @foreach ($private_keys as $key) + + @endforeach + +

+ This SSH key will be automatically added to your Hetzner account and used to access the + server. +

+ @endif +
+
+ + @foreach ($hetznerSshKeys as $sshKey) + + @endforeach + +
+ +
+ +
+ + +
+
+ +
+
+ + @if ($saved_cloud_init_scripts->count() > 0) +
+ + + @foreach ($saved_cloud_init_scripts as $script) + + @endforeach + + + Clear + +
+ @endif +
+ + +
+ +
+ +
+
+
+ +
+ + Back + + + Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }} + +
+
+ @endif + @endif + @endif +
\ No newline at end of file diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index a25e245e9..5f99ad97f 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -1,4 +1,4 @@ -
+
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify @@ -9,6 +9,89 @@

General

+ @if ($server->hetzner_server_id) +
+
+ + + + + @if ($hetznerServerStatus) + + @if (in_array($hetznerServerStatus, ['starting', 'initializing'])) + + + + + + @endif + $hetznerServerStatus === 'running', + 'text-red-500' => $hetznerServerStatus === 'off', + ])> + {{ ucfirst($hetznerServerStatus) }} + + + @else + + + + + + + Checking status... + + @endif +
+ + +
+ @if ($server->cloudProviderToken && !$server->isFunctional() && $hetznerServerStatus === 'off') + + Power On + + @endif + @endif + @if ($isValidating) +
+ + + + + + Validating... +
+ @endif @if ($server->id === 0) @else - Save + Save @if ($server->isFunctional()) Validate & configure @@ -36,7 +120,21 @@ @else You can't use this server until it is validated. @endif - @if ((!$isReachable || !$isUsable) && $server->id !== 0) + @if ($isValidating) +
+ + Validation in Progress + + + + +
+ @endif + @if ( + (!$isReachable || !$isUsable) && + $server->id !== 0 && + !$isValidating && + !in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off'])) Validate & configure @@ -69,12 +167,15 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 @endif
- - + + @if (!$isSwarmWorker && !$isBuildServer) + helper='A wildcard domain allows you to receive a randomly generated domain for your new applications.

For instance, if you set "https://example.com" as your wildcard domain, your applications will receive domains like "https://randomId.example.com".' + :disabled="$isValidating" /> @endif
@@ -82,11 +183,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 + required :disabled="$isValidating" />
- + + label="Port" required :disabled="$isValidating" />
@@ -96,53 +198,72 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 helper="Server's timezone. This is used for backups, cron jobs, etc." />
@can('update', $server) -
+ @if ($isValidating)
- - +
-
- +
+ @else +
+
+
+ + + + +
+
+ +
-
+ @endif @else
- + @@ -160,7 +281,7 @@ class="w-full input opacity-50 cursor-not-allowed" label="Use it as a build server?" /> @else + id="isBuildServer" label="Use it as a build server?" :disabled="$isValidating" /> @endif
@@ -180,7 +301,7 @@ class="w-full input opacity-50 cursor-not-allowed" + label="Is it a Swarm Manager?" :disabled="$isValidating" /> @endif @if ($server->settings->is_swarm_manager) @@ -191,7 +312,7 @@ class="w-full input opacity-50 cursor-not-allowed" + label="Is it a Swarm Worker?" :disabled="$isValidating" /> @endif
@endif @@ -208,32 +329,34 @@ class="w-full input opacity-50 cursor-not-allowed"
@if ($server->isSentinelLive()) - Save - Restart + Save + Restart Sentinel Logs - Logs + Logs @else - Save - Sync + Save + Sync Sentinel Logs - Logs + Logs @endif
@@ -242,14 +365,14 @@ class="w-full input opacity-50 cursor-not-allowed"
+ label="Enable Sentinel" :disabled="$isValidating" /> @if ($server->isSentinelEnabled()) @if (isDev()) + label="Enable Sentinel (with debug)" instantSave :disabled="$isValidating" /> @endif + id="isMetricsEnabled" label="Enable Metrics" :disabled="$isValidating" /> @else @if (isDev()) isSentinelEnabled())
+ label="Sentinel token" required helper="Token for Sentinel." :disabled="$isValidating" /> Regenerate + wire:click="regenerateSentinelToken" :disabled="$isValidating">Regenerate
+ helper="URL to your Coolify instance. If it is empty that means you do not have a FQDN set for your Coolify instance." + :disabled="$isValidating" />
+ helper="Interval used for gathering metrics. Lower values result in more disk space usage." + :disabled="$isValidating" /> + helper="Number of days to retain metrics data for." :disabled="$isValidating" /> + helper="Interval at which metrics data is sent to the collector." + :disabled="$isValidating" />
@endif diff --git a/resources/views/livewire/server/validate-and-install.blade.php b/resources/views/livewire/server/validate-and-install.blade.php index 814f81652..572da85e8 100644 --- a/resources/views/livewire/server/validate-and-install.blade.php +++ b/resources/views/livewire/server/validate-and-install.blade.php @@ -123,6 +123,9 @@ @isset($error)
{!! $error !!}
+ + Retry Validation + @endisset @endif
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index aed2ef55d..0d6e7c559 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -17,7 +17,7 @@ @if ($servers->count() > 0) - + @foreach ($servers as $server) @if ($loop->first) @@ -31,7 +31,7 @@ @endif @endforeach @endforeach - + Connect @else diff --git a/routes/web.php b/routes/web.php index fd2ed8730..703f80ab5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ use App\Livewire\Notifications\Pushover as NotificationPushover; use App\Livewire\Notifications\Slack as NotificationSlack; use App\Livewire\Notifications\Telegram as NotificationTelegram; +use App\Livewire\Notifications\Webhook as NotificationWebhook; use App\Livewire\Profile\Index as ProfileIndex; use App\Livewire\Project\Application\Configuration as ApplicationConfiguration; use App\Livewire\Project\Application\Deployment\Index as DeploymentIndex; @@ -34,12 +35,15 @@ use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; +use App\Livewire\Security\CloudInitScripts; +use App\Livewire\Security\CloudTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; use App\Livewire\Server\Advanced as ServerAdvanced; use App\Livewire\Server\CaCertificate\Show as CaCertificateShow; use App\Livewire\Server\Charts as ServerCharts; use App\Livewire\Server\CloudflareTunnel; +use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow; use App\Livewire\Server\Delete as DeleteServer; use App\Livewire\Server\Destinations as ServerDestinations; use App\Livewire\Server\DockerCleanup; @@ -125,6 +129,7 @@ Route::get('/discord', NotificationDiscord::class)->name('notifications.discord'); Route::get('/slack', NotificationSlack::class)->name('notifications.slack'); Route::get('/pushover', NotificationPushover::class)->name('notifications.pushover'); + Route::get('/webhook', NotificationWebhook::class)->name('notifications.webhook'); }); Route::prefix('storages')->group(function () { @@ -247,6 +252,7 @@ Route::get('/', ServerShow::class)->name('server.show'); Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); + Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token'); Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate'); Route::get('/resources', ResourcesShow::class)->name('server.resources'); Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); @@ -271,6 +277,8 @@ // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); + Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); + Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); }); diff --git a/tests/Feature/CloudInitScriptTest.php b/tests/Feature/CloudInitScriptTest.php new file mode 100644 index 000000000..881f0071c --- /dev/null +++ b/tests/Feature/CloudInitScriptTest.php @@ -0,0 +1,101 @@ + 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params) + ->toHaveKey('user_data') + ->and($params['user_data'])->toBe("#!/bin/bash\necho 'Hello World'"); +}); + +it('validates cloud-init script is not included when empty', function () { + $cloudInitScript = null; + $params = [ + 'name' => 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params)->not->toHaveKey('user_data'); +}); + +it('validates cloud-init script is not included when empty string', function () { + $cloudInitScript = ''; + $params = [ + 'name' => 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params)->not->toHaveKey('user_data'); +}); + +it('validates cloud-init script with multiline content', function () { + $cloudInitScript = "#cloud-config\n\npackages:\n - nginx\n - git\n\nruncmd:\n - systemctl start nginx"; + $params = [ + 'name' => 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params) + ->toHaveKey('user_data') + ->and($params['user_data'])->toContain('#cloud-config') + ->and($params['user_data'])->toContain('packages:') + ->and($params['user_data'])->toContain('runcmd:'); +}); diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php new file mode 100644 index 000000000..c939c0041 --- /dev/null +++ b/tests/Feature/HetznerServerCreationTest.php @@ -0,0 +1,136 @@ + $enableIpv4, + 'enable_ipv6' => $enableIpv6, + ]; + + expect($publicNet)->toBe([ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ]); +}); + +it('validates public_net configuration with IPv4 only', function () { + $enableIpv4 = true; + $enableIpv6 = false; + + $publicNet = [ + 'enable_ipv4' => $enableIpv4, + 'enable_ipv6' => $enableIpv6, + ]; + + expect($publicNet)->toBe([ + 'enable_ipv4' => true, + 'enable_ipv6' => false, + ]); +}); + +it('validates public_net configuration with IPv6 only', function () { + $enableIpv4 = false; + $enableIpv6 = true; + + $publicNet = [ + 'enable_ipv4' => $enableIpv4, + 'enable_ipv6' => $enableIpv6, + ]; + + expect($publicNet)->toBe([ + 'enable_ipv4' => false, + 'enable_ipv6' => true, + ]); +}); + +it('validates IP address selection prefers IPv4 when both are enabled', function () { + $enableIpv4 = true; + $enableIpv6 = true; + + $hetznerServer = [ + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ]; + + $ipAddress = null; + if ($enableIpv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; + } elseif ($enableIpv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + } + + expect($ipAddress)->toBe('1.2.3.4'); +}); + +it('validates IP address selection uses IPv6 when only IPv6 is enabled', function () { + $enableIpv4 = false; + $enableIpv6 = true; + + $hetznerServer = [ + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ]; + + $ipAddress = null; + if ($enableIpv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; + } elseif ($enableIpv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + } + + expect($ipAddress)->toBe('2001:db8::1'); +}); + +it('validates SSH key array merging logic with Coolify key', function () { + $coolifyKeyId = 123; + $selectedHetznerKeys = []; + + $sshKeys = array_merge( + [$coolifyKeyId], + $selectedHetznerKeys + ); + $sshKeys = array_unique($sshKeys); + $sshKeys = array_values($sshKeys); + + expect($sshKeys)->toBe([123]) + ->and(count($sshKeys))->toBe(1); +}); + +it('validates SSH key array merging with additional Hetzner keys', function () { + $coolifyKeyId = 123; + $selectedHetznerKeys = [456, 789]; + + $sshKeys = array_merge( + [$coolifyKeyId], + $selectedHetznerKeys + ); + $sshKeys = array_unique($sshKeys); + $sshKeys = array_values($sshKeys); + + expect($sshKeys)->toBe([123, 456, 789]) + ->and(count($sshKeys))->toBe(3); +}); + +it('validates deduplication when Coolify key is also in selected keys', function () { + $coolifyKeyId = 123; + $selectedHetznerKeys = [123, 456, 789]; + + $sshKeys = array_merge( + [$coolifyKeyId], + $selectedHetznerKeys + ); + $sshKeys = array_unique($sshKeys); + $sshKeys = array_values($sshKeys); + + expect($sshKeys)->toBe([123, 456, 789]) + ->and(count($sshKeys))->toBe(3); +}); diff --git a/tests/Unit/CloudInitScriptValidationTest.php b/tests/Unit/CloudInitScriptValidationTest.php new file mode 100644 index 000000000..bb4657502 --- /dev/null +++ b/tests/Unit/CloudInitScriptValidationTest.php @@ -0,0 +1,76 @@ +toBeFalse() + ->and($hasValue)->toBeFalse(); +}); + +it('validates cloud-init script name is required when saving', function () { + $saveScript = true; + $scriptName = 'My Installation Script'; + + $isNameRequired = $saveScript; + $hasName = ! empty($scriptName); + + expect($isNameRequired)->toBeTrue() + ->and($hasName)->toBeTrue(); +}); + +it('validates cloud-init script description is optional', function () { + $scriptDescription = null; + + $isDescriptionRequired = false; + $hasDescription = ! empty($scriptDescription); + + expect($isDescriptionRequired)->toBeFalse() + ->and($hasDescription)->toBeFalse(); +}); + +it('validates save_cloud_init_script must be boolean', function () { + $saveCloudInitScript = true; + + expect($saveCloudInitScript)->toBeBool(); +}); + +it('validates save_cloud_init_script defaults to false', function () { + $saveCloudInitScript = false; + + expect($saveCloudInitScript)->toBeFalse(); +}); + +it('validates cloud-init script can be a bash script', function () { + $cloudInitScript = "#!/bin/bash\napt-get update\napt-get install -y nginx"; + + expect($cloudInitScript)->toBeString() + ->and($cloudInitScript)->toContain('#!/bin/bash'); +}); + +it('validates cloud-init script can be cloud-config yaml', function () { + $cloudInitScript = "#cloud-config\npackages:\n - nginx\n - git"; + + expect($cloudInitScript)->toBeString() + ->and($cloudInitScript)->toContain('#cloud-config'); +}); + +it('validates script name max length is 255 characters', function () { + $scriptName = str_repeat('a', 255); + + expect(strlen($scriptName))->toBe(255) + ->and(strlen($scriptName))->toBeLessThanOrEqual(255); +}); + +it('validates script name exceeding 255 characters should be invalid', function () { + $scriptName = str_repeat('a', 256); + + $isValid = strlen($scriptName) <= 255; + + expect($isValid)->toBeFalse() + ->and(strlen($scriptName))->toBeGreaterThan(255); +}); diff --git a/tests/Unit/DatalistComponentTest.php b/tests/Unit/DatalistComponentTest.php new file mode 100644 index 000000000..12699c30a --- /dev/null +++ b/tests/Unit/DatalistComponentTest.php @@ -0,0 +1,68 @@ +required)->toBeFalse() + ->and($component->disabled)->toBeFalse() + ->and($component->readonly)->toBeFalse() + ->and($component->multiple)->toBeFalse() + ->and($component->instantSave)->toBeFalse() + ->and($component->defaultClass)->toBe('input'); +}); + +it('uses provided id', function () { + $component = new Datalist(id: 'test-datalist'); + + expect($component->id)->toBe('test-datalist'); +}); + +it('accepts multiple selection mode', function () { + $component = new Datalist(multiple: true); + + expect($component->multiple)->toBeTrue(); +}); + +it('accepts instantSave parameter', function () { + $component = new Datalist(instantSave: 'customSave'); + + expect($component->instantSave)->toBe('customSave'); +}); + +it('accepts placeholder', function () { + $component = new Datalist(placeholder: 'Select an option...'); + + expect($component->placeholder)->toBe('Select an option...'); +}); + +it('accepts autofocus', function () { + $component = new Datalist(autofocus: true); + + expect($component->autofocus)->toBeTrue(); +}); + +it('accepts disabled state', function () { + $component = new Datalist(disabled: true); + + expect($component->disabled)->toBeTrue(); +}); + +it('accepts readonly state', function () { + $component = new Datalist(readonly: true); + + expect($component->readonly)->toBeTrue(); +}); + +it('accepts authorization properties', function () { + $component = new Datalist( + canGate: 'update', + canResource: 'resource', + autoDisable: false + ); + + expect($component->canGate)->toBe('update') + ->and($component->canResource)->toBe('resource') + ->and($component->autoDisable)->toBeFalse(); +}); diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php new file mode 100644 index 000000000..6cb9f0bb3 --- /dev/null +++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php @@ -0,0 +1,114 @@ +toBeInstanceOf(HetznerDeletionFailed::class) + ->and($notification->hetznerServerId)->toBe(12345) + ->and($notification->teamId)->toBe(1) + ->and($notification->errorMessage)->toBe('Hetzner API error: Server not found'); +}); + +it('uses hetzner_deletion_failed event for channels', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 12345, + teamId: 1, + errorMessage: 'Test error' + ); + + $mockNotifiable = Mockery::mock(); + $mockNotifiable->shouldReceive('getEnabledChannels') + ->with('hetzner_deletion_failed') + ->once() + ->andReturn([]); + + $channels = $notification->via($mockNotifiable); + + expect($channels)->toBeArray(); +}); + +it('generates correct mail content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 67890, + teamId: 1, + errorMessage: 'Connection timeout' + ); + + $mail = $notification->toMail(); + + expect($mail->subject)->toBe('Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #67890') + ->and($mail->view)->toBe('emails.hetzner-deletion-failed') + ->and($mail->viewData['hetznerServerId'])->toBe(67890) + ->and($mail->viewData['errorMessage'])->toBe('Connection timeout'); +}); + +it('generates correct discord content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 11111, + teamId: 1, + errorMessage: 'API rate limit exceeded' + ); + + $discord = $notification->toDiscord(); + + expect($discord->title)->toContain('Failed to delete Hetzner server') + ->and($discord->description)->toContain('#11111') + ->and($discord->description)->toContain('API rate limit exceeded') + ->and($discord->description)->toContain('may still exist in your Hetzner Cloud account'); +}); + +it('generates correct telegram content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 22222, + teamId: 1, + errorMessage: 'Invalid token' + ); + + $telegram = $notification->toTelegram(); + + expect($telegram)->toBeArray() + ->and($telegram)->toHaveKey('message') + ->and($telegram['message'])->toContain('#22222') + ->and($telegram['message'])->toContain('Invalid token') + ->and($telegram['message'])->toContain('ACTION REQUIRED'); +}); + +it('generates correct pushover content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 33333, + teamId: 1, + errorMessage: 'Network error' + ); + + $pushover = $notification->toPushover(); + + expect($pushover->title)->toBe('Hetzner Server Deletion Failed') + ->and($pushover->level)->toBe('error') + ->and($pushover->message)->toContain('#33333') + ->and($pushover->message)->toContain('Network error'); +}); + +it('generates correct slack content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 44444, + teamId: 1, + errorMessage: 'Permission denied' + ); + + $slack = $notification->toSlack(); + + expect($slack->title)->toContain('Hetzner Server Deletion Failed') + ->and($slack->description)->toContain('#44444') + ->and($slack->description)->toContain('Permission denied'); +}); diff --git a/tests/Unit/HetznerSshKeysTest.php b/tests/Unit/HetznerSshKeysTest.php new file mode 100644 index 000000000..06c6b06e6 --- /dev/null +++ b/tests/Unit/HetznerSshKeysTest.php @@ -0,0 +1,53 @@ +toBe([123, 456, 789]) + ->and(count($sshKeys))->toBe(3); +}); + +it('removes duplicate SSH key IDs', function () { + $coolifyKeyId = 123; + $selectedHetznerKeys = [123, 456, 789]; // User also selected Coolify key + + // Simulate the merge and deduplication logic + $sshKeys = array_merge( + [$coolifyKeyId], + $selectedHetznerKeys + ); + $sshKeys = array_unique($sshKeys); + $sshKeys = array_values($sshKeys); + + expect($sshKeys)->toBe([123, 456, 789]) + ->and(count($sshKeys))->toBe(3); +}); + +it('works with no selected Hetzner keys', function () { + $coolifyKeyId = 123; + $selectedHetznerKeys = []; + + // Simulate the merge logic + $sshKeys = array_merge( + [$coolifyKeyId], + $selectedHetznerKeys + ); + + expect($sshKeys)->toBe([123]) + ->and(count($sshKeys))->toBe(1); +}); + +it('validates SSH key IDs are integers', function () { + $selectedHetznerKeys = [456, 789, 1011]; + + foreach ($selectedHetznerKeys as $keyId) { + expect($keyId)->toBeInt(); + } +}); diff --git a/tests/Unit/Rules/ValidCloudInitYamlTest.php b/tests/Unit/Rules/ValidCloudInitYamlTest.php new file mode 100644 index 000000000..f3ea906af --- /dev/null +++ b/tests/Unit/Rules/ValidCloudInitYamlTest.php @@ -0,0 +1,174 @@ +validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts valid cloud-config YAML without header', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'YAML' +users: + - name: demo + groups: sudo +packages: + - nginx +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts valid bash script with shebang', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'BASH' +#!/bin/bash +apt update +apt install -y nginx +systemctl start nginx +BASH; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts empty or null script', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $rule->validate('script', '', function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); + + $rule->validate('script', null, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('rejects invalid YAML format', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + $errorMessage = ''; + + $script = <<<'YAML' +#cloud-config +users: + - name: demo + groups: sudo + invalid_indentation +packages: + - nginx +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) { + $valid = false; + $errorMessage = $message; + }); + + expect($valid)->toBeFalse(); + expect($errorMessage)->toContain('YAML'); +}); + +it('rejects script that is neither bash nor valid YAML', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + $errorMessage = ''; + + $script = <<<'INVALID' +this is not valid YAML + and has invalid indentation: + - item + without proper structure { +INVALID; + + $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) { + $valid = false; + $errorMessage = $message; + }); + + expect($valid)->toBeFalse(); + expect($errorMessage)->toContain('bash script'); +}); + +it('accepts complex cloud-config with multiple sections', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'YAML' +#cloud-config +users: + - name: coolify + groups: sudo, docker + shell: /bin/bash + sudo: ['ALL=(ALL) NOPASSWD:ALL'] + ssh_authorized_keys: + - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... + +packages: + - docker.io + - docker-compose + - git + - curl + +package_update: true +package_upgrade: true + +runcmd: + - systemctl enable docker + - systemctl start docker + - usermod -aG docker coolify + - echo "Server setup complete" + +write_files: + - path: /etc/docker/daemon.json + content: | + { + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } + } +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php new file mode 100644 index 000000000..859262c3e --- /dev/null +++ b/tests/Unit/ValidHostnameTest.php @@ -0,0 +1,74 @@ +validate('server_name', $hostname, function () use (&$failCalled) { + $failCalled = true; + }); + + expect($failCalled)->toBeFalse(); +})->with([ + 'simple hostname' => 'myserver', + 'hostname with hyphen' => 'my-server', + 'hostname with numbers' => 'server123', + 'hostname starting with number' => '123server', + 'all numeric hostname' => '12345', + 'fqdn' => 'server.example.com', + 'subdomain' => 'web.app.example.com', + 'max label length' => str_repeat('a', 63), + 'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59), +]); + +it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) { + $rule = new ValidHostname; + $failCalled = false; + $errorMessage = ''; + + $rule->validate('server_name', $hostname, function ($message) use (&$failCalled, &$errorMessage) { + $failCalled = true; + $errorMessage = $message; + }); + + expect($failCalled)->toBeTrue(); + expect($errorMessage)->toContain($expectedError); +})->with([ + 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'], + 'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'], + 'starts with dot' => ['.myserver', 'cannot start or end with a dot'], + 'ends with dot' => ['myserver.', 'cannot start or end with a dot'], + 'consecutive dots' => ['my..server', 'consecutive dots'], + 'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'], + 'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'], + 'empty label' => ['my..server', 'consecutive dots'], + 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], +]); + +it('accepts empty hostname', function () { + $rule = new ValidHostname; + $failCalled = false; + + $rule->validate('server_name', '', function () use (&$failCalled) { + $failCalled = true; + }); + + expect($failCalled)->toBeFalse(); +}); + +it('trims whitespace before validation', function () { + $rule = new ValidHostname; + $failCalled = false; + + $rule->validate('server_name', ' myserver ', function () use (&$failCalled) { + $failCalled = true; + }); + + expect($failCalled)->toBeFalse(); +});