Skip to content

Commit dade409

Browse files
committed
feat: implement ACME setup service and integrate it into server provisioning workflow
1 parent b52985e commit dade409

File tree

7 files changed

+371
-145
lines changed

7 files changed

+371
-145
lines changed

modules/Platform/app/Console/ServerSetupAcmeCommand.php

Lines changed: 14 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,27 @@
22

33
namespace Modules\Platform\Console;
44

5-
use App\Enums\ActivityAction;
6-
use App\Traits\ActivityTrait;
7-
use Exception;
85
use Illuminate\Console\Command;
9-
use Illuminate\Support\Str;
106
use Modules\Platform\Models\Server;
11-
use Modules\Platform\Services\ServerSSHService;
7+
use Modules\Platform\Services\ServerAcmeSetupService;
128

139
/**
14-
* One-time acme.sh setup on a Hestia server.
10+
* ACME setup entrypoint for a Hestia server.
1511
*
1612
* Creates the 'asterossl' user, installs acme.sh, registers a Let's Encrypt
17-
* account, and uploads the SSL helper scripts. This is a standalone command
18-
* (not a provisioning step) — run it once per server before provisioning websites.
13+
* account, and uploads the SSL helper scripts. This command remains available
14+
* for manual repair runs even though standard server provisioning now performs
15+
* the same setup automatically.
1916
*/
2017
class ServerSetupAcmeCommand extends Command
2118
{
22-
use ActivityTrait;
23-
2419
protected $signature = 'platform:server:setup-acme
2520
{server_id : The ID of the server to configure}
2621
{--force : Re-upload scripts even if server is already configured}';
2722

2823
protected $description = 'Install acme.sh and configure SSL tooling on a Hestia server.';
2924

30-
public function handle(ServerSSHService $sshService): int
25+
public function handle(ServerAcmeSetupService $acmeSetupService): int
3126
{
3227
$serverId = $this->argument('server_id');
3328
$server = Server::query()->findOrFail($serverId);
@@ -38,109 +33,16 @@ public function handle(ServerSSHService $sshService): int
3833
return self::SUCCESS;
3934
}
4035

41-
// If already configured but --force is set, skip setup steps and only re-upload scripts
42-
if ($server->acme_configured && $this->option('force')) {
43-
$this->info(sprintf('Re-uploading SSL scripts to server "%s" (#%d)...', $server->name, $server->id));
44-
45-
$this->uploadHelperScripts($sshService, $server);
46-
47-
$this->info('✅ Scripts re-uploaded successfully.');
48-
49-
return self::SUCCESS;
50-
}
51-
52-
$this->info(sprintf('Setting up acme.sh on server "%s" (#%d)...', $server->name, $server->id));
53-
54-
// Derive email from server name: "Hestia SG1" → "hestia-sg1@astero.net.in"
55-
$acmeEmail = Str::slug($server->name).'@astero.net.in';
56-
57-
// Step 1: Create asterossl user (idempotent — ignores if already exists)
58-
$this->line('Creating asterossl user...');
59-
$result = $sshService->executeCommand(
60-
$server,
61-
'id asterossl &>/dev/null || useradd --system --shell /bin/bash --create-home --home-dir /home/asterossl asterossl',
62-
120
63-
);
64-
throw_unless($result['success'], Exception::class, 'Failed to create asterossl user: '.($result['message'] ?? 'Unknown error'));
65-
66-
// Step 2: Install acme.sh under asterossl user
67-
$this->line('Installing acme.sh...');
68-
$result = $sshService->executeCommand(
69-
$server,
70-
sprintf(
71-
'sudo -u asterossl -H bash -c "cd /tmp && curl -s https://get.acme.sh | sh -s -- email=%s" 2>&1',
72-
escapeshellarg($acmeEmail)
73-
),
74-
180
75-
);
76-
throw_unless($result['success'], Exception::class, 'Failed to install acme.sh: '.($result['message'] ?? 'Unknown error'));
77-
78-
// Step 3: Set default CA to Let's Encrypt and register account
79-
$this->line('Setting default CA to Let\'s Encrypt...');
80-
$result = $sshService->executeCommand(
81-
$server,
82-
'sudo -u asterossl -H /home/asterossl/.acme.sh/acme.sh --set-default-ca --server letsencrypt 2>&1',
83-
30
84-
);
85-
throw_unless($result['success'], Exception::class, 'Failed to set default CA: '.($result['message'] ?? 'Unknown error'));
86-
87-
$this->line('Registering Let\'s Encrypt account...');
88-
$result = $sshService->executeCommand(
89-
$server,
90-
sprintf(
91-
'sudo -u asterossl -H /home/asterossl/.acme.sh/acme.sh --register-account --server letsencrypt -m %s 2>&1',
92-
escapeshellarg($acmeEmail)
93-
),
94-
60
95-
);
96-
throw_unless($result['success'], Exception::class, 'Failed to register LE account: '.($result['message'] ?? 'Unknown error'));
36+
$this->info(sprintf(
37+
'%s server "%s" (#%d)...',
38+
$this->option('force') ? 'Refreshing ACME scripts on' : 'Setting up ACME on',
39+
$server->name,
40+
$server->id
41+
));
9742

98-
// Step 5: Create cert storage directory
99-
$this->line('Creating SSL store directory...');
100-
$result = $sshService->executeCommand($server, 'sudo -u asterossl -H mkdir -p /home/asterossl/.ssl-store', 30);
101-
throw_unless($result['success'], Exception::class, 'Failed to create .ssl-store: '.($result['message'] ?? 'Unknown error'));
102-
103-
// Step 6: Upload helper scripts via SSH (base64 encoding — SFTP is unavailable on Hestia)
104-
$this->line('Uploading SSL helper scripts...');
105-
$this->uploadHelperScripts($sshService, $server);
106-
107-
// Mark server as acme-configured
108-
$server->acme_configured = true;
109-
$server->acme_email = $acmeEmail;
110-
$server->save();
111-
112-
$successMessage = sprintf('acme.sh setup completed on server "%s" (email: %s)', $server->name, $acmeEmail);
113-
$this->logActivity($server, ActivityAction::UPDATE, $successMessage);
114-
$this->info(''.$successMessage);
43+
$result = $acmeSetupService->setup($server, (bool) $this->option('force'));
44+
$this->info($result['summary']);
11545

11646
return self::SUCCESS;
11747
}
118-
119-
/**
120-
* Upload SSL helper scripts to the server via SSH (base64 encoded).
121-
*/
122-
private function uploadHelperScripts(ServerSSHService $sshService, Server $server): void
123-
{
124-
$scriptsDir = '/usr/local/hestia/data/astero/bin';
125-
$result = $sshService->executeCommand($server, sprintf('mkdir -p %s', $scriptsDir), 30);
126-
throw_unless($result['success'], Exception::class, 'Failed to create scripts directory: '.($result['message'] ?? 'Unknown error'));
127-
128-
$localBinDir = base_path('hestia/bin');
129-
$scripts = ['a-issue-wildcard-ssl', 'a-check-wildcard-ssl', 'a-renew-wildcard-ssl'];
130-
131-
foreach ($scripts as $script) {
132-
$localPath = $localBinDir.'/'.$script;
133-
$remotePath = $scriptsDir.'/'.$script;
134-
135-
throw_unless(file_exists($localPath), Exception::class, sprintf('Local script not found: %s', $localPath));
136-
137-
$base64Content = base64_encode(file_get_contents($localPath));
138-
$uploadResult = $sshService->executeCommand(
139-
$server,
140-
sprintf('echo %s | base64 -d > %s && chmod 755 %s', escapeshellarg($base64Content), $remotePath, $remotePath),
141-
30
142-
);
143-
throw_unless($uploadResult['success'], Exception::class, sprintf('Failed to upload %s: %s', $script, $uploadResult['message'] ?? 'Unknown error'));
144-
}
145-
}
14648
}

modules/Platform/app/Http/Controllers/ServerController.php

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
use Illuminate\Http\Request;
1414
use Illuminate\Routing\Controllers\HasMiddleware;
1515
use Illuminate\Routing\Controllers\Middleware;
16-
use Illuminate\Support\Facades\Artisan;
1716
use Illuminate\Support\Facades\DB;
1817
use Inertia\Inertia;
1918
use Inertia\Response;
@@ -27,6 +26,7 @@
2726
use Modules\Platform\Models\Secret;
2827
use Modules\Platform\Models\Server;
2928
use Modules\Platform\Models\Website;
29+
use Modules\Platform\Services\ServerAcmeSetupService;
3030
use Modules\Platform\Services\ServerService;
3131
use Modules\Platform\Services\ServerSSHService;
3232
use Modules\Platform\Services\SSHKeyService;
@@ -531,28 +531,14 @@ public function setupAcme(Request $request, int|string $id): JsonResponse|Redire
531531
}
532532

533533
try {
534-
$exitCode = Artisan::call('platform:server:setup-acme', [
535-
'server_id' => $server->id,
536-
]);
537-
538-
if ($exitCode === 0) {
539-
$message = 'acme.sh setup completed successfully.';
540-
$this->logActivity($server, ActivityAction::UPDATE, $message);
541-
542-
if ($request->expectsJson()) {
543-
return response()->json(['status' => 'success', 'message' => $message]);
544-
}
545-
546-
return back()->with('success', $message);
547-
}
548-
549-
$message = 'acme.sh setup failed. Check server logs for details.';
534+
$result = resolve(ServerAcmeSetupService::class)->setup($server);
535+
$message = $result['summary'];
550536

551537
if ($request->expectsJson()) {
552-
return response()->json(['status' => 'error', 'message' => $message], 500);
538+
return response()->json(['status' => 'success', 'message' => $message]);
553539
}
554540

555-
return back()->with('error', $message);
541+
return back()->with('success', $message);
556542
} catch (Exception $exception) {
557543
$message = 'acme.sh setup failed: '.$exception->getMessage();
558544

@@ -1142,6 +1128,11 @@ protected function getProvisioningStepsConfig(): array
11421128
'description' => 'Configure HestiaCP, PHP, and Astero directories',
11431129
'icon' => 'ri-settings-3-line',
11441130
],
1131+
'acme_setup' => [
1132+
'title' => 'ACME Setup',
1133+
'description' => 'Install acme.sh and wildcard SSL helper scripts',
1134+
'icon' => 'ri-shield-check-line',
1135+
],
11451136
'release_api_key' => [
11461137
'title' => 'Release API Key',
11471138
'description' => 'Configure release API key on the target server',
@@ -1167,6 +1158,11 @@ protected function getProvisioningStepsConfig(): array
11671158
'description' => 'Sync server information and stats',
11681159
'icon' => 'ri-macbook-line',
11691160
],
1161+
'pg_optimize' => [
1162+
'title' => 'Optimize PostgreSQL',
1163+
'description' => 'Apply PostgreSQL settings based on server resources',
1164+
'icon' => 'ri-database-2-line',
1165+
],
11701166
];
11711167
}
11721168

modules/Platform/app/Jobs/ServerProvision.php

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Illuminate\Support\Sleep;
1818
use Modules\Platform\Libs\HestiaClient;
1919
use Modules\Platform\Models\Server;
20+
use Modules\Platform\Services\ServerAcmeSetupService;
2021
use Modules\Platform\Services\ServerService;
2122
use Modules\Platform\Services\ServerSSHService;
2223
use RecursiveDirectoryIterator;
@@ -33,9 +34,10 @@
3334
* 2. Install HestiaCP (if not installed)
3435
* 3. Upload Astero scripts
3536
* 4. Prepare server for Astero provisioning
36-
* 5. Create admin access key
37-
* 6. Verify installation
38-
* 7. Update server with credentials and mark as ready
37+
* 5. Configure ACME SSL automation
38+
* 6. Create admin access key
39+
* 7. Verify installation
40+
* 8. Update server with credentials and mark as ready
3941
*
4042
* Progress is tracked in server metadata['provisioning_steps'].
4143
*/
@@ -91,6 +93,7 @@ class ServerProvision implements ShouldQueue
9193
'server_reboot' => 'Rebooting server',
9294
'scripts_upload' => 'Uploading Astero scripts',
9395
'server_setup' => 'Setting up server',
96+
'acme_setup' => 'Setting up ACME SSL',
9497
'release_api_key' => 'Configuring release API key',
9598
'access_key' => 'Creating access key',
9699
'verification' => 'Verifying installation',
@@ -109,6 +112,7 @@ class ServerProvision implements ShouldQueue
109112
'server_reboot',
110113
'scripts_upload',
111114
'server_setup',
115+
'acme_setup',
112116
'release_api_key',
113117
'access_key',
114118
'verification',
@@ -171,8 +175,11 @@ public function retryUntil(): DateTimeInterface
171175
/**
172176
* Execute the job.
173177
*/
174-
public function handle(ServerSSHService $sshService, ServerService $serverService): void
175-
{
178+
public function handle(
179+
ServerSSHService $sshService,
180+
ServerService $serverService,
181+
ServerAcmeSetupService $acmeSetupService
182+
): void {
176183
$this->queueMonitorLabel('Server #'.$this->serverId);
177184
/** @var Server|null $server */
178185
$server = Server::query()->find($this->serverId);
@@ -287,15 +294,27 @@ public function handle(ServerSSHService $sshService, ServerService $serverServic
287294
$this->updateStep($server, 'server_setup', 'completed', $serverSetupResult);
288295
}
289296

290-
// Step 7: Configure release API key for secured release sync
297+
// Step 7: Install ACME tooling and wildcard SSL helper scripts
298+
if (! $this->isStepDone($server, 'acme_setup')) {
299+
if ((bool) $server->acme_configured) {
300+
$this->updateStep($server, 'acme_setup', 'skipped', ['reason' => 'Already configured']);
301+
} else {
302+
$this->abortIfProvisioningStopRequested($server);
303+
$this->updateStep($server, 'acme_setup', 'running');
304+
$acmeSetupResult = $acmeSetupService->setup($server);
305+
$this->updateStep($server, 'acme_setup', 'completed', $acmeSetupResult);
306+
}
307+
}
308+
309+
// Step 8: Configure release API key for secured release sync
291310
if (! $this->isStepDone($server, 'release_api_key')) {
292311
$this->abortIfProvisioningStopRequested($server);
293312
$this->updateStep($server, 'release_api_key', 'running');
294313
$releaseKeyResult = $this->configureReleaseApiKey($server, $sshService);
295314
$this->updateStep($server, 'release_api_key', 'completed', $releaseKeyResult);
296315
}
297316

298-
// Step 8: Create access key
317+
// Step 9: Create access key
299318
$credentials = null;
300319
if (! $this->isStepDone($server, 'access_key')) {
301320
$this->abortIfProvisioningStopRequested($server);
@@ -310,31 +329,31 @@ public function handle(ServerSSHService $sshService, ServerService $serverServic
310329
]);
311330
}
312331

313-
// Step 9: Verify installation
332+
// Step 10: Verify installation
314333
if (! $this->isStepDone($server, 'verification')) {
315334
$this->abortIfProvisioningStopRequested($server);
316335
$this->updateStep($server, 'verification', 'running');
317336
$verificationResult = $this->verifyInstallation($server, $sshService);
318337
$this->updateStep($server, 'verification', 'completed', $verificationResult);
319338
}
320339

321-
// Step 10: Update releases
340+
// Step 11: Update releases
322341
if (! $this->isStepDone($server, 'update_releases')) {
323342
$this->abortIfProvisioningStopRequested($server);
324343
$this->updateStep($server, 'update_releases', 'running');
325344
$updateReleaseResult = $this->updateReleases($server, $sshService, $serverService);
326345
$this->updateStep($server, 'update_releases', 'completed', $updateReleaseResult);
327346
}
328347

329-
// Step 11: Sync server info
348+
// Step 12: Sync server info
330349
if (! $this->isStepDone($server, 'server_sync')) {
331350
$this->abortIfProvisioningStopRequested($server);
332351
$this->updateStep($server, 'server_sync', 'running');
333352
$syncServerResult = $this->syncServer($server, $serverService);
334353
$this->updateStep($server, 'server_sync', 'completed', $syncServerResult);
335354
}
336355

337-
// Step 12: Optimize PostgreSQL settings
356+
// Step 13: Optimize PostgreSQL settings
338357
if (! $this->isStepDone($server, 'pg_optimize')) {
339358
$this->abortIfProvisioningStopRequested($server);
340359
$this->updateStep($server, 'pg_optimize', 'running');

0 commit comments

Comments
 (0)