Force SSL and Multi SSL (#456)

This commit is contained in:
Saeed Vaziry 2025-02-01 01:33:04 +01:00 committed by GitHub
parent ea396786e4
commit 262c5e040d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 161 additions and 73 deletions

View File

@ -0,0 +1,16 @@
<?php
namespace App\Actions\SSL;
use App\Models\Ssl;
class ActivateSSL
{
public function activate(Ssl $ssl): void
{
$ssl->site->ssls()->update(['is_active' => false]);
$ssl->is_active = true;
$ssl->save();
$ssl->site->webserver()->updateVHost($ssl->site);
}
}

View File

@ -31,6 +31,7 @@ public function create(Site $site, array $input): void
'expires_at' => $input['type'] === SslType::LETSENCRYPT ? now()->addMonths(3) : $input['expires_at'], 'expires_at' => $input['type'] === SslType::LETSENCRYPT ? now()->addMonths(3) : $input['expires_at'],
'status' => SslStatus::CREATING, 'status' => SslStatus::CREATING,
'email' => $input['email'] ?? null, 'email' => $input['email'] ?? null,
'is_active' => ! $site->activeSsl,
]); ]);
$ssl->domains = [$site->domain]; $ssl->domains = [$site->domain];
if (isset($input['aliases']) && $input['aliases']) { if (isset($input['aliases']) && $input['aliases']) {

View File

@ -75,6 +75,7 @@ class Site extends AbstractModel
'port', 'port',
'progress', 'progress',
'user', 'user',
'force_ssl',
]; ];
protected $casts = [ protected $casts = [
@ -84,6 +85,7 @@ class Site extends AbstractModel
'progress' => 'integer', 'progress' => 'integer',
'aliases' => 'array', 'aliases' => 'array',
'source_control_id' => 'integer', 'source_control_id' => 'integer',
'force_ssl' => 'boolean',
]; ];
public static array $statusColors = [ public static array $statusColors = [
@ -227,6 +229,7 @@ public function activeSsl(): HasOne
return $this->hasOne(Ssl::class) return $this->hasOne(Ssl::class)
->where('expires_at', '>=', now()) ->where('expires_at', '>=', now())
->where('status', SslStatus::CREATED) ->where('status', SslStatus::CREATED)
->where('is_active', true)
->orderByDesc('id'); ->orderByDesc('id');
} }

View File

@ -17,10 +17,13 @@
* @property Carbon $expires_at * @property Carbon $expires_at
* @property string $status * @property string $status
* @property Site $site * @property Site $site
* @property string $ca_path
* @property ?array $domains * @property ?array $domains
* @property int $log_id * @property int $log_id
* @property string $email * @property string $email
* @property bool $is_active
* @property string $certificate_path
* @property string $pk_path
* @property string $ca_path
* @property ?ServerLog $log * @property ?ServerLog $log
*/ */
class Ssl extends AbstractModel class Ssl extends AbstractModel
@ -38,6 +41,10 @@ class Ssl extends AbstractModel
'domains', 'domains',
'log_id', 'log_id',
'email', 'email',
'is_active',
'certificate_path',
'pk_path',
'ca_path',
]; ];
protected $casts = [ protected $casts = [
@ -48,6 +55,7 @@ class Ssl extends AbstractModel
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'domains' => 'array', 'domains' => 'array',
'log_id' => 'integer', 'log_id' => 'integer',
'is_active' => 'boolean',
]; ];
public static array $statusColors = [ public static array $statusColors = [
@ -62,58 +70,6 @@ public function site(): BelongsTo
return $this->belongsTo(Site::class); 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 public function validateSetup(string $result): bool
{ {
if (! Str::contains($result, 'Successfully received certificate')) { if (! Str::contains($result, 'Successfully received certificate')) {
@ -121,8 +77,8 @@ public function validateSetup(string $result): bool
} }
if ($this->type == 'letsencrypt') { if ($this->type == 'letsencrypt') {
$this->certificate = $this->getCertsDirectoryPath().'/fullchain.pem'; $this->certificate_path = '/etc/letsencrypt/live/'.$this->id.'/fullchain.pem';
$this->pk = $this->getCertsDirectoryPath().'/privkey.pem'; $this->pk_path = '/etc/letsencrypt/live/'.$this->id.'/privkey.pem';
$this->save(); $this->save();
} }
@ -145,13 +101,4 @@ public function log(): BelongsTo
{ {
return $this->belongsTo(ServerLog::class); return $this->belongsTo(ServerLog::class);
} }
public function getEmailAttribute(?string $value): string
{
if ($value) {
return $value;
}
return $this->site->server->creator->email;
}
} }

View File

@ -169,16 +169,19 @@ public function setupSSL(Ssl $ssl): void
} }
$command = view('ssh.services.webserver.nginx.create-letsencrypt-ssl', [ $command = view('ssh.services.webserver.nginx.create-letsencrypt-ssl', [
'email' => $ssl->email, 'email' => $ssl->email,
'domain' => $ssl->site->domain, 'name' => $ssl->id,
'domains' => $domains, 'domains' => $domains,
]); ]);
if ($ssl->type == 'custom') { 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', [ $command = view('ssh.services.webserver.nginx.create-custom-ssl', [
'path' => $ssl->getCertsDirectoryPath(), 'path' => dirname($ssl->certificate_path),
'certificate' => $ssl->certificate, 'certificate' => $ssl->certificate,
'pk' => $ssl->pk, 'pk' => $ssl->pk,
'certificatePath' => $ssl->getCertificatePath(), 'certificatePath' => $ssl->certificate_path,
'pkPath' => $ssl->getPkPath(), 'pkPath' => $ssl->pk_path,
]); ]);
} }
$result = $this->service->server->ssh()->setLog($ssl->log)->exec( $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 public function removeSSL(Ssl $ssl): void
{ {
$this->service->server->ssh()->exec( $this->service->server->ssh()->exec(
'sudo rm -rf '.$ssl->getCertsDirectoryPath().'*', 'sudo rm -rf '.dirname($ssl->certificate_path).'*',
'remove-ssl', 'remove-ssl',
$ssl->site_id $ssl->site_id
); );

View File

@ -15,6 +15,7 @@
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Support\Enums\MaxWidth; use Filament\Support\Enums\MaxWidth;
class Index extends Page class Index extends Page
@ -44,6 +45,24 @@ protected function getHeaderActions(): array
->color('gray') ->color('gray')
->url('https://vitodeploy.com/sites/ssl') ->url('https://vitodeploy.com/sites/ssl')
->openUrlInNewTab(), ->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') CreateAction::make('create')
->label('New Certificate') ->label('New Certificate')
->icon('heroicon-o-lock-closed') ->icon('heroicon-o-lock-closed')

View File

@ -2,11 +2,14 @@
namespace App\Web\Pages\Servers\Sites\Pages\SSL\Widgets; namespace App\Web\Pages\Servers\Sites\Pages\SSL\Widgets;
use App\Actions\SSL\ActivateSSL;
use App\Actions\SSL\DeleteSSL; use App\Actions\SSL\DeleteSSL;
use App\Models\Site; use App\Models\Site;
use App\Models\Ssl; use App\Models\Ssl;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget; use Filament\Widgets\TableWidget as Widget;
@ -27,6 +30,9 @@ protected function getTableQuery(): Builder
protected function getTableColumns(): array protected function getTableColumns(): array
{ {
return [ 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') TextColumn::make('type')
->searchable() ->searchable()
->sortable(), ->sortable(),
@ -52,6 +58,25 @@ public function table(Table $table): Table
->query($this->getTableQuery()) ->query($this->getTableQuery())
->columns($this->getTableColumns()) ->columns($this->getTableColumns())
->actions([ ->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') Action::make('logs')
->hiddenLabel() ->hiddenLabel()
->tooltip('Logs') ->tooltip('Logs')

View File

@ -82,7 +82,9 @@ public function getHeaderActions(): array
if (in_array(SiteFeature::DEPLOYMENT, $this->site->type()->supportedFeatures())) { if (in_array(SiteFeature::DEPLOYMENT, $this->site->type()->supportedFeatures())) {
$actions[] = $this->deployAction(); $actions[] = $this->deployAction();
if ($this->site->sourceControl) {
$actionsGroup[] = $this->autoDeploymentAction(); $actionsGroup[] = $this->autoDeploymentAction();
}
$actionsGroup[] = $this->deploymentScriptAction(); $actionsGroup[] = $this->deploymentScriptAction();
} }

View File

@ -0,0 +1,54 @@
<?php
use App\Enums\SslType;
use App\Models\Site;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ssls', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 1 1 9 0v3.75M3.75 21.75h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H3.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>

After

Width:  |  Height:  |  Size: 374 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#16a34a">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@ -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 echo 'VITO_SSH_ERROR' && exit 1
fi fi

View File

@ -33,8 +33,8 @@
@endif @endif
@if ($site->activeSsl) @if ($site->activeSsl)
listen 443 ssl; listen 443 ssl;
ssl_certificate {{ $site->activeSsl->getCertificatePath() }}; ssl_certificate {{ $site->activeSsl->certificate_path }};
ssl_certificate_key {{ $site->activeSsl->getPkPath() }}; ssl_certificate_key {{ $site->activeSsl->pk_path }};
@endif @endif
server_name {{ $site->domain }} {{ $site->getAliasesString() }}; server_name {{ $site->domain }} {{ $site->getAliasesString() }};

View File

@ -48,12 +48,17 @@ public function test_letsencrypt_ssl()
]) ])
->assertSuccessful(); ->assertSuccessful();
$ssl = Ssl::query()->where('site_id', $this->site->id)->first();
$this->assertNotEmpty($ssl);
$this->assertDatabaseHas('ssls', [ $this->assertDatabaseHas('ssls', [
'site_id' => $this->site->id, 'site_id' => $this->site->id,
'type' => SslType::LETSENCRYPT, 'type' => SslType::LETSENCRYPT,
'status' => SslStatus::CREATED, 'status' => SslStatus::CREATED,
'domains' => json_encode([$this->site->domain]), 'domains' => json_encode([$this->site->domain]),
'email' => 'ssl@example.com', '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(); ->assertSuccessful();
$ssl = Ssl::query()->where('site_id', $this->site->id)->first();
$this->assertNotEmpty($ssl);
$this->assertDatabaseHas('ssls', [ $this->assertDatabaseHas('ssls', [
'site_id' => $this->site->id, 'site_id' => $this->site->id,
'type' => SslType::CUSTOM, 'type' => SslType::CUSTOM,
'status' => SslStatus::CREATED, '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',
]); ]);
} }