diff --git a/app/Actions/SSL/ActivateSSL.php b/app/Actions/SSL/ActivateSSL.php new file mode 100644 index 0000000..4cf8f64 --- /dev/null +++ b/app/Actions/SSL/ActivateSSL.php @@ -0,0 +1,16 @@ +site->ssls()->update(['is_active' => false]); + $ssl->is_active = true; + $ssl->save(); + $ssl->site->webserver()->updateVHost($ssl->site); + } +} diff --git a/app/Actions/SSL/CreateSSL.php b/app/Actions/SSL/CreateSSL.php index 08215b1..5f2392e 100644 --- a/app/Actions/SSL/CreateSSL.php +++ b/app/Actions/SSL/CreateSSL.php @@ -31,6 +31,7 @@ public function create(Site $site, array $input): void 'expires_at' => $input['type'] === SslType::LETSENCRYPT ? now()->addMonths(3) : $input['expires_at'], 'status' => SslStatus::CREATING, 'email' => $input['email'] ?? null, + 'is_active' => ! $site->activeSsl, ]); $ssl->domains = [$site->domain]; if (isset($input['aliases']) && $input['aliases']) { diff --git a/app/Models/Site.php b/app/Models/Site.php index d36e0df..8d8083a 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -75,6 +75,7 @@ class Site extends AbstractModel 'port', 'progress', 'user', + 'force_ssl', ]; protected $casts = [ @@ -84,6 +85,7 @@ class Site extends AbstractModel 'progress' => 'integer', 'aliases' => 'array', 'source_control_id' => 'integer', + 'force_ssl' => 'boolean', ]; public static array $statusColors = [ @@ -227,6 +229,7 @@ public function activeSsl(): HasOne return $this->hasOne(Ssl::class) ->where('expires_at', '>=', now()) ->where('status', SslStatus::CREATED) + ->where('is_active', true) ->orderByDesc('id'); } diff --git a/app/Models/Ssl.php b/app/Models/Ssl.php index fb827b6..7131448 100644 --- a/app/Models/Ssl.php +++ b/app/Models/Ssl.php @@ -17,10 +17,13 @@ * @property Carbon $expires_at * @property string $status * @property Site $site - * @property string $ca_path * @property ?array $domains * @property int $log_id * @property string $email + * @property bool $is_active + * @property string $certificate_path + * @property string $pk_path + * @property string $ca_path * @property ?ServerLog $log */ class Ssl extends AbstractModel @@ -38,6 +41,10 @@ class Ssl extends AbstractModel 'domains', 'log_id', 'email', + 'is_active', + 'certificate_path', + 'pk_path', + 'ca_path', ]; protected $casts = [ @@ -48,6 +55,7 @@ class Ssl extends AbstractModel 'expires_at' => 'datetime', 'domains' => 'array', 'log_id' => 'integer', + 'is_active' => 'boolean', ]; public static array $statusColors = [ @@ -62,58 +70,6 @@ public function site(): BelongsTo return $this->belongsTo(Site::class); } - public function getCertsDirectoryPath(): ?string - { - if ($this->type == 'letsencrypt') { - return '/etc/letsencrypt/live/'.$this->site->domain; - } - - if ($this->type == 'custom') { - return '/etc/ssl/'.$this->site->domain; - } - - return ''; - } - - public function getCertificatePath(): ?string - { - if ($this->type == 'letsencrypt') { - return $this->certificate; - } - - if ($this->type == 'custom') { - return $this->getCertsDirectoryPath().'/cert.pem'; - } - - return ''; - } - - public function getPkPath(): ?string - { - if ($this->type == 'letsencrypt') { - return $this->pk; - } - - if ($this->type == 'custom') { - return $this->getCertsDirectoryPath().'/privkey.pem'; - } - - return ''; - } - - public function getCaPath(): ?string - { - if ($this->type == 'letsencrypt') { - return $this->ca; - } - - if ($this->type == 'custom') { - return $this->getCertsDirectoryPath().'/fullchain.pem'; - } - - return ''; - } - public function validateSetup(string $result): bool { if (! Str::contains($result, 'Successfully received certificate')) { @@ -121,8 +77,8 @@ public function validateSetup(string $result): bool } if ($this->type == 'letsencrypt') { - $this->certificate = $this->getCertsDirectoryPath().'/fullchain.pem'; - $this->pk = $this->getCertsDirectoryPath().'/privkey.pem'; + $this->certificate_path = '/etc/letsencrypt/live/'.$this->id.'/fullchain.pem'; + $this->pk_path = '/etc/letsencrypt/live/'.$this->id.'/privkey.pem'; $this->save(); } @@ -145,13 +101,4 @@ public function log(): BelongsTo { return $this->belongsTo(ServerLog::class); } - - public function getEmailAttribute(?string $value): string - { - if ($value) { - return $value; - } - - return $this->site->server->creator->email; - } } diff --git a/app/SSH/Services/Webserver/Nginx.php b/app/SSH/Services/Webserver/Nginx.php index 3c85ab0..73a423d 100755 --- a/app/SSH/Services/Webserver/Nginx.php +++ b/app/SSH/Services/Webserver/Nginx.php @@ -169,16 +169,19 @@ public function setupSSL(Ssl $ssl): void } $command = view('ssh.services.webserver.nginx.create-letsencrypt-ssl', [ 'email' => $ssl->email, - 'domain' => $ssl->site->domain, + 'name' => $ssl->id, 'domains' => $domains, ]); if ($ssl->type == 'custom') { + $ssl->certificate_path = '/etc/ssl/'.$ssl->id.'/cert.pem'; + $ssl->pk_path = '/etc/ssl/'.$ssl->id.'/privkey.pem'; + $ssl->save(); $command = view('ssh.services.webserver.nginx.create-custom-ssl', [ - 'path' => $ssl->getCertsDirectoryPath(), + 'path' => dirname($ssl->certificate_path), 'certificate' => $ssl->certificate, 'pk' => $ssl->pk, - 'certificatePath' => $ssl->getCertificatePath(), - 'pkPath' => $ssl->getPkPath(), + 'certificatePath' => $ssl->certificate_path, + 'pkPath' => $ssl->pk_path, ]); } $result = $this->service->server->ssh()->setLog($ssl->log)->exec( @@ -197,7 +200,7 @@ public function setupSSL(Ssl $ssl): void public function removeSSL(Ssl $ssl): void { $this->service->server->ssh()->exec( - 'sudo rm -rf '.$ssl->getCertsDirectoryPath().'*', + 'sudo rm -rf '.dirname($ssl->certificate_path).'*', 'remove-ssl', $ssl->site_id ); diff --git a/app/Web/Pages/Servers/Sites/Pages/SSL/Index.php b/app/Web/Pages/Servers/Sites/Pages/SSL/Index.php index 1b24af9..b9893e0 100644 --- a/app/Web/Pages/Servers/Sites/Pages/SSL/Index.php +++ b/app/Web/Pages/Servers/Sites/Pages/SSL/Index.php @@ -15,6 +15,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Get; +use Filament\Notifications\Notification; use Filament\Support\Enums\MaxWidth; class Index extends Page @@ -44,6 +45,24 @@ protected function getHeaderActions(): array ->color('gray') ->url('https://vitodeploy.com/sites/ssl') ->openUrlInNewTab(), + Action::make('force-ssl') + ->label('Force SSL') + ->tooltip(fn () => $this->site->force_ssl ? 'Disable force SSL' : 'Enable force SSL') + ->icon(fn () => $this->site->force_ssl ? 'icon-force-ssl-enabled' : 'icon-force-ssl-disabled') + ->requiresConfirmation() + ->modalSubmitActionLabel(fn () => $this->site->force_ssl ? 'Disable' : 'Enable') + ->action(function () { + $this->site->update([ + 'force_ssl' => ! $this->site->force_ssl, + ]); + $this->site->webserver()->updateVHost($this->site); + Notification::make() + ->success() + ->title('SSL status has been updated.') + ->send(); + $this->dispatch('$refresh'); + }) + ->color('gray'), CreateAction::make('create') ->label('New Certificate') ->icon('heroicon-o-lock-closed') diff --git a/app/Web/Pages/Servers/Sites/Pages/SSL/Widgets/SslsList.php b/app/Web/Pages/Servers/Sites/Pages/SSL/Widgets/SslsList.php index 94f0480..aaedbed 100644 --- a/app/Web/Pages/Servers/Sites/Pages/SSL/Widgets/SslsList.php +++ b/app/Web/Pages/Servers/Sites/Pages/SSL/Widgets/SslsList.php @@ -2,11 +2,14 @@ namespace App\Web\Pages\Servers\Sites\Pages\SSL\Widgets; +use App\Actions\SSL\ActivateSSL; use App\Actions\SSL\DeleteSSL; use App\Models\Site; use App\Models\Ssl; +use Filament\Notifications\Notification; use Filament\Tables\Actions\Action; use Filament\Tables\Actions\DeleteAction; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Filament\Widgets\TableWidget as Widget; @@ -27,6 +30,9 @@ protected function getTableQuery(): Builder protected function getTableColumns(): array { return [ + IconColumn::make('is_active') + ->color(fn (Ssl $record) => $record->is_active ? 'green' : 'gray') + ->icon(fn (Ssl $record) => $record->is_active ? 'heroicon-o-lock-closed' : 'heroicon-o-lock-open'), TextColumn::make('type') ->searchable() ->sortable(), @@ -52,6 +58,25 @@ public function table(Table $table): Table ->query($this->getTableQuery()) ->columns($this->getTableColumns()) ->actions([ + Action::make('activate-ssl') + ->hiddenLabel() + ->visible(fn (Ssl $record) => ! $record->is_active) + ->tooltip('Activate SSL') + ->icon('heroicon-o-lock-closed') + ->authorize(fn (Ssl $record) => auth()->user()->can('update', [$record->site, $this->site->server])) + ->requiresConfirmation() + ->modalHeading('Activate SSL') + ->modalSubmitActionLabel('Activate') + ->action(function (Ssl $record) { + run_action($this, function () use ($record) { + app(ActivateSSL::class)->activate($record); + + Notification::make() + ->success() + ->title('SSL has been activated.') + ->send(); + }); + }), Action::make('logs') ->hiddenLabel() ->tooltip('Logs') diff --git a/app/Web/Pages/Servers/Sites/View.php b/app/Web/Pages/Servers/Sites/View.php index d431a96..f6073b0 100644 --- a/app/Web/Pages/Servers/Sites/View.php +++ b/app/Web/Pages/Servers/Sites/View.php @@ -82,7 +82,9 @@ public function getHeaderActions(): array if (in_array(SiteFeature::DEPLOYMENT, $this->site->type()->supportedFeatures())) { $actions[] = $this->deployAction(); - $actionsGroup[] = $this->autoDeploymentAction(); + if ($this->site->sourceControl) { + $actionsGroup[] = $this->autoDeploymentAction(); + } $actionsGroup[] = $this->deploymentScriptAction(); } diff --git a/database/migrations/2025_01_31_155828_update_ssls_table.php b/database/migrations/2025_01_31_155828_update_ssls_table.php new file mode 100644 index 0000000..4bf5ba6 --- /dev/null +++ b/database/migrations/2025_01_31_155828_update_ssls_table.php @@ -0,0 +1,54 @@ +boolean('is_active')->default(false); + $table->string('certificate_path')->nullable(); + $table->string('pk_path')->nullable(); + $table->string('ca_path')->nullable(); + }); + Site::query()->chunk(100, function ($sites) { + foreach ($sites as $site) { + foreach ($site->ssls as $ssl) { + if ($ssl->type === SslType::LETSENCRYPT) { + $ssl->certificate_path = $ssl->certificate; + $ssl->pk_path = $ssl->pk; + $ssl->ca_path = $ssl->ca; + $ssl->certificate = null; + $ssl->pk = null; + $ssl->ca = null; + } + if ($ssl->type === SslType::CUSTOM) { + $ssl->certificate_path = '/etc/ssl/'.$ssl->site->domain.'/cert.pem'; + $ssl->pk_path = '/etc/ssl/'.$ssl->site->domain.'/privkey.pem'; + $ssl->ca_path = '/etc/ssl/'.$ssl->site->domain.'/fullchain.pem'; + } + $ssl->save(); + } + $activeSSL = $site->ssls()->where('expires_at', '>=', now())->latest()->first(); + if ($activeSSL) { + $activeSSL->update(['is_active' => true]); + } + } + }); + } + + public function down(): void + { + Schema::table('ssls', function (Blueprint $table) { + $table->dropColumn('is_active'); + $table->dropColumn('certificate_path'); + $table->dropColumn('pk_path'); + $table->dropColumn('ca_path'); + }); + } +}; diff --git a/resources/svg/force-ssl-disabled.svg b/resources/svg/force-ssl-disabled.svg new file mode 100644 index 0000000..8b942f1 --- /dev/null +++ b/resources/svg/force-ssl-disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/svg/force-ssl-enabled.svg b/resources/svg/force-ssl-enabled.svg new file mode 100644 index 0000000..72db4e8 --- /dev/null +++ b/resources/svg/force-ssl-enabled.svg @@ -0,0 +1,4 @@ + + + diff --git a/resources/views/ssh/services/webserver/nginx/create-letsencrypt-ssl.blade.php b/resources/views/ssh/services/webserver/nginx/create-letsencrypt-ssl.blade.php index 032ac6a..d818b38 100644 --- a/resources/views/ssh/services/webserver/nginx/create-letsencrypt-ssl.blade.php +++ b/resources/views/ssh/services/webserver/nginx/create-letsencrypt-ssl.blade.php @@ -1,3 +1,3 @@ -if ! sudo certbot certonly --force-renewal --nginx --noninteractive --agree-tos --cert-name {{ $domain }} -m {{ $email }} {{ $domains }} --verbose; then +if ! sudo certbot certonly --force-renewal --nginx --noninteractive --agree-tos --cert-name {{ $name }} -m {{ $email }} {{ $domains }} --verbose; then echo 'VITO_SSH_ERROR' && exit 1 fi diff --git a/resources/views/ssh/services/webserver/nginx/vhost.blade.php b/resources/views/ssh/services/webserver/nginx/vhost.blade.php index f81ce8b..eb0bb31 100755 --- a/resources/views/ssh/services/webserver/nginx/vhost.blade.php +++ b/resources/views/ssh/services/webserver/nginx/vhost.blade.php @@ -33,8 +33,8 @@ @endif @if ($site->activeSsl) listen 443 ssl; - ssl_certificate {{ $site->activeSsl->getCertificatePath() }}; - ssl_certificate_key {{ $site->activeSsl->getPkPath() }}; + ssl_certificate {{ $site->activeSsl->certificate_path }}; + ssl_certificate_key {{ $site->activeSsl->pk_path }}; @endif server_name {{ $site->domain }} {{ $site->getAliasesString() }}; diff --git a/tests/Feature/SslTest.php b/tests/Feature/SslTest.php index 1d4e67f..ec918f2 100644 --- a/tests/Feature/SslTest.php +++ b/tests/Feature/SslTest.php @@ -48,12 +48,17 @@ public function test_letsencrypt_ssl() ]) ->assertSuccessful(); + $ssl = Ssl::query()->where('site_id', $this->site->id)->first(); + $this->assertNotEmpty($ssl); + $this->assertDatabaseHas('ssls', [ 'site_id' => $this->site->id, 'type' => SslType::LETSENCRYPT, 'status' => SslStatus::CREATED, 'domains' => json_encode([$this->site->domain]), 'email' => 'ssl@example.com', + 'certificate_path' => '/etc/letsencrypt/live/'.$ssl->id.'/fullchain.pem', + 'pk_path' => '/etc/letsencrypt/live/'.$ssl->id.'/privkey.pem', ]); } @@ -101,10 +106,16 @@ public function test_custom_ssl() ]) ->assertSuccessful(); + $ssl = Ssl::query()->where('site_id', $this->site->id)->first(); + $this->assertNotEmpty($ssl); + $this->assertDatabaseHas('ssls', [ 'site_id' => $this->site->id, 'type' => SslType::CUSTOM, 'status' => SslStatus::CREATED, + 'domains' => json_encode([$this->site->domain]), + 'certificate_path' => '/etc/ssl/'.$ssl->id.'/cert.pem', + 'pk_path' => '/etc/ssl/'.$ssl->id.'/privkey.pem', ]); }