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 @@